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.
This article details my approach to implementing this feature using Next.js, Redux, Redux Persist, and Redux State Sync.
The Challenge I Faced
My goal was to create 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 and cross-tab synchronization.
My Solution: Redux, Redux Persist, and Redux State Sync
To address these challenges, I implemented state management with Redux Persist and Redux State Sync. Here's how I approached it:
Storage Implementation
First, I created a hybrid storage solution to accommodate both client-side and 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();
This storage configuration ensures that my application can run on both the client and server without issues.
Redux State Structure
I designed the Redux state to encapsulate the essential data for cross-tab synchronization. In this case, we only 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 then implemented the assetSlice
with appropriate actions and reducers:
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
I setup the Redux configuration 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);
Certain actions are also blacklisted to ensure compatibility.
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 created, I can call this into any component as long as it is wrapped in the Redux provider. Here's 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, it significantly enhances user experience in multi-tab scenarios.
In my case, this solution not only improved user engagement but also increased the probability of users revisiting the site.
You can view the full source code on GitHub or try it out on wanderer.moe.