Go Back
22 June 2024

Redux Cross-Tab State Syncing

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:

  1. Share state across the entire application
  2. Persist state through page refreshes and tab closures
  3. 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.