Note: This post was written before wanderer.moe was rebranded to skowt.cc-written content has been updated to reflect these changes.
While building the mass download feature for skowt.cc, I needed users to select multiple assets and download them in one click. State had to sync in real-time across browser tabs.
## The Challenge
I needed state that could:
- Share across the entire application
- Persist through page refreshes and tab closures
- Synchronize in real-time across multiple browser tabs
React's Context API handles cross-component state well. It fails at persistence and cross-tab sync.
Solution: Redux, Redux Persist, and Redux State Sync.
### Storage Implementation
First, a storage adapter that handles both client and server rendering:
1import createWebStorage from "redux-persist/es/storage/createWebStorage";
2
3const createNoopStorage = () => {
4 return {
5 getItem() {
6 return Promise.resolve(null);
7 },
8 setItem(_key: string, value: number) {
9 return Promise.resolve(value);
10 },
11 removeItem() {
12 return Promise.resolve();
13 },
14 };
15};
16
17export const storage =
18 typeof window !== "undefined"
19 ? createWebStorage("local")
20 : createNoopStorage();
### Redux State Structure
Two pieces of state:
isMassDownloading: Boolean flag tracking download progress across tabs
selectedAssets: Array of selected assets
1import { createSlice, PayloadAction } from "@reduxjs/toolkit";
2import type { Asset } from "~/lib/types";
3
4export interface IAssetState {
5 isMassDownloading: boolean;
6 selectedAssets: Asset[];
7}
8
9const initialState: IAssetState = {
10 isMassDownloading: false,
11 selectedAssets: [],
12};
### Redux Slice Implementation
An assetSlice with actions and reducers managing asset selection and download state:
1export const assetSlice = createSlice({
2 name: "assets",
3 initialState,
4 reducers: {
5 setSelectedAssets: (state, action: PayloadAction<Asset[]>) => {
6 state.selectedAssets = action.payload || [];
7 },
8 setIsMassDownloading: (state, action: PayloadAction<boolean>) => {
9 state.isMassDownloading = action.payload || false;
10 },
11 toggleAssetSelection: (state, action: PayloadAction<Asset>) => {
12 if (state.isMassDownloading) return;
13
14 const index = state.selectedAssets.findIndex(
15 (asset) => asset.path === action.payload.path,
16 );
17 if (index >= 0) {
18 state.selectedAssets.splice(index, 1);
19 } else {
20 state.selectedAssets.push(action.payload);
21 }
22 },
23 clearSelectedAssets: (state) => {
24 state.selectedAssets = [];
25 },
26 },
27});
### Redux Configuration
persistReducer and combineReducers persist state across sessions:
1import { combineReducers } from "@reduxjs/toolkit";
2import { persistReducer } from "redux-persist";
3import { storage } from "./storage";
4import assetSlice from "./slice/asset-slice";
5
6export const persistConfig = {
7 key: "root",
8 storage: storage,
9 whitelist: ["assets"],
10};
11
12const rootReducer = combineReducers({
13 assets: assetSlice,
14});
15
16export const persistedReducer = persistReducer(persistConfig, rootReducer);
The Redux store uses createStateSyncMiddleware and initMessageListener to synchronize state across tabs:
1import { configureStore } from "@reduxjs/toolkit";
2import { persistedReducer } from "./reducer";
3import {
4 createStateSyncMiddleware,
5 initMessageListener,
6} from "redux-state-sync";
7import {
8 useDispatch,
9 TypedUseSelectorHook,
10 useSelector,
11 useStore,
12} from "react-redux";
13import logger from "redux-logger";
14import { persistStore } from "redux-persist";
15
16const blacklist = ["persist/PERSIST", "persist/REHYDRATE"];
17
18export const store = configureStore({
19 reducer: persistedReducer,
20 middleware: (getDefaultMiddleware) =>
21 getDefaultMiddleware().prepend(
22 logger,
23 createStateSyncMiddleware({
24 predicate: (action) => {
25 if (typeof action !== "function") {
26 if (Array.isArray(blacklist)) {
27 return blacklist.indexOf(action.type) < 0;
28 }
29 }
30 return false;
31 },
32 }),
33 ) as any, // typescript complains
34});
35
36export const persistor = persistStore(store);
37
38initMessageListener(store);
### Redux Provider
Wrap the application with Provider and PersistGate:
1"use client";
2
3import { Provider } from "react-redux";
4import { PersistGate } from "redux-persist/integration/react";
5import { store, persistor } from "./store";
6
7export const ReduxProvider = ({ children }: { children: React.ReactNode }) => {
8 return (
9 <Provider store={store}>
10 <PersistGate loading={null} persistor={persistor}>
11 {children}
12 </PersistGate>
13 </Provider>
14 );
15};
16
17export default ReduxProvider;
## Component Integration
Access state and dispatch actions from any component:
1import { useAppDispatch, useAppSelector } from "~/redux/store";
2
3const dispatch = useAppDispatch();
4
5const isSelected = isAssetSelected(
6 useAppSelector((state) => state.assets),
7 asset,
8);
Dispatching actions is straightforward:
1<button onClick={() => dispatch(toggleAssetSelection(asset))}>
2 {isSelected ? "Deselect" : "Select"}
3</button>
### Download Indicator
A local context manages the download indicator state, comparing it with global Redux state to determine what to render:
1const {
2 isUnsharedMassDownloading,
3 setIsUnsharedMassDownloading,
4 isIndicatorOpen,
5} = useContext(AssetDownloadIndicatorContext);
6
7const isMassDownloading = useAppSelector(
8 (state) => state.assets.isMassDownloading,
9);
10
11
12{isUnsharedMassDownloading ? (
13 <ShowMassDownloadProgress />
14) : null}
15
16{isMassDownloading && !isUnsharedMassDownloading ? (
17 <MassDownloadInProgress />
18) : null}
19
## Conclusion
Cross-tab state synchronization improved engagement on skowt.cc. Users appreciate continuity when switching between tabs, and it encourages them to revisit the site.
View the full source code on GitHub or try it yourself on skowt.cc.