While working on mass downloading for wanderer.moe, I wanted to implement a feature that would allow users to select multiple assets and download them in a single click.
To enhance the user experience further, I also wanted to synchronize the selected assets and download state across multiple browser tabs in real-time.
## The Challenge I Faced
I needed a system that could:
- Share state across the entire application
- Persist state through page refreshes and tab closures
- Synchronize state in real-time across multiple browser tabs
I quickly realized that while React's Context API can handle cross-component state sharing, it falls short for persistence & having cross-tab synchronization.
My Solution: Redux, Redux Persist, and Redux State Sync.
### Storage Implementation
First, we need a storage implementation that correctly accommodate both client-side & server-side 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
In this use case, we only really need to manage:
isMassDownloading
: A boolean flag to indicate if mass downloading, to compare against the local state for the download indicator.
selectedAssets
: An array of the 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
I created a slice named assetSlice
with the appropriate actions and reducers, where asset selection/mass download state is managed:
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
The redux configuration is setup with the persistReducer
and combineReducers
functions to persist the 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);
Then, I configured the Redux store with the createStateSyncMiddleware
and initMessageListener
to synchronize the 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
Finally, we import Provider
and PersistGate
from react-redux
and redux-persist/integration/react
to wrap the application with the Redux provider:
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
With the custom hooks, I can call this into any component as long as it is wrapped in the Redux provider. Here's a snipper example of how I implemented features for the asset-item.tsx
component:
1import { useAppDispatch, useAppSelector } from "~/redux/store";
2
3const dispatch = useAppDispatch();
4
5const isSelected = isAssetSelected(
6 useAppSelector((state) => state.assets),
7 asset,
8);
With the above, you can access the Redux state and dispatch actions directly from the component.
1<button onClick={() => dispatch(toggleAssetSelection(asset))}>
2 {isSelected ? "Deselect" : "Select"}
3</button>
### Download Indicator
I created a local context to manage the download indicator state, where I can compare the local state with the global state to determine what should be conditionally rendered:
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
While implementing cross-tab state synchronization may seem complex, and even unnecessary in some cases, adding this to wanderer.moe increased engagement and the probability of users revisiting the site.
You can view the full source code on GitHub or try it out for yourself on wanderer.moe.