reactjsreduxredux-toolkitredux-persist

Creating multiple records of the same type in Redux-Persist


I'm building an online store using React with Redux-Persist. I have an idea to store the user's cart in local storage, creating a separate entry in the local storage for each user (I think it would be more convenient). However, I'm not sure how to switch Redux-Persist to the right setup after changing the user. Currently, I have a general entry with the key 'basket'. I'd like to have 'basket/userId' for each user.

Here's my code:

// store.ts

import { combineReducers, configureStore } from '@reduxjs/toolkit';
import { FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE, persistReducer, persistStore } from "redux-persist";
import storage from "redux-persist/lib/storage";
import basketSlice from './slices/basket/basket.slice';
import categoriesSlice from "./slices/category.slice";
import ordersSlice from './slices/orders.slice';
import userSlice from "./slices/user/user.slice";

const rootReducer = combineReducers({
  basket: basketSlice,
  orders: ordersSlice,
  categories: categoriesSlice,
  user: userSlice
});

const persistConfig = {
  key: `basket`, // I want "basket/userId" for each user
  storage,
  whitelist: ['basket'],
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) => {
    return getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE],
      },
    });
  },
});

export const persistor = persistStore(store);

export type RootType = ReturnType<typeof rootReducer>;
// user.slice.ts

import { getValueFromLocalStorage } from "@/functions/getValueFromLocalStorage";
import { createSlice } from '@reduxjs/toolkit';
import { checkAuth, auth, logout } from "./user.actions";
import { IInitialState } from "./user.interface";

const initialState: IInitialState = {
  user: getValueFromLocalStorage('user'),
  isLoading: false
}
 
const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(auth.pending, (state) => {
      state.isLoading = true;
    })
    
    .addCase(auth.fulfilled, (state, action) => {
      state.isLoading = false;
      state.user = action.payload.user;
    })
      
    .addCase(auth.rejected, (state) => {
      state.isLoading = false;
      state.user = null;
    })

    .addCase(logout.fulfilled, (state) => {
      state.isLoading = false;
      state.user = null;
    })

    .addCase(checkAuth.fulfilled, (state, action) => {
      state.user = action.payload.user;
    })
  }
});

export default userSlice.reducer;




// user.actions.ts

import { errorCatch } from '@/api/api.helper';
import { AuthType } from '@/components/screens/Auth/auth.types';
import { removeFromStorage } from '@/services/auth/auth.helper';
import AuthService from '@/services/auth/auth.service';
import { createAsyncThunk } from '@reduxjs/toolkit';
import { IAuthResponse, IEmailPassword } from './user.interface';

export const auth = createAsyncThunk<
  IAuthResponse,
  { type: AuthType; data: IEmailPassword }
>(`auth`, async ({ type, data }, thunkApi) => {
  try {
    const response = await AuthService.main(type, data);
    return response;
  } catch (error) {
    return thunkApi.rejectWithValue(error);
  }
});

export const logout = createAsyncThunk('auth/logout', async () =>
  removeFromStorage()
);

export const checkAuth = createAsyncThunk<IAuthResponse>(
  'auth/check-auth',
  async (_, thunkApi) => {
    try {
      const response = await AuthService.getNewTokens();
      return response.data;
    } catch (error) {
      if (errorCatch(error) === 'jwt expired') thunkApi.dispatch(logout());

      return thunkApi.rejectWithValue(error);
    }
  }
);

// App.tsx

import { Toaster } from '@/components/ui/toaster';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Provider } from 'react-redux';
import { PersistGate } from 'redux-persist/integration/react';
import Paths from './components/routes/Routes';
import { persistor, store } from './store/store';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Provider store={store}>
        <PersistGate loading={null} persistor={persistor}>
          <Paths />
          <Toaster />
        </PersistGate>
        <ReactQueryDevtools initialIsOpen={false} />
      </Provider>
    </QueryClientProvider>
  );
}

export default App;
// basketSlice.ts

import { IBasketItem } from "@/interfaces/basket.interface";
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IBasketSlice } from "./basket.types";

const initialState: IBasketSlice = {
  products: null
};
 
const basketSlice = createSlice({
  name: 'basket',
  initialState,
  reducers: {
    addProductToBasket(state, action: PayloadAction<IBasketItem>) {
      if (state.products) state.products.push(action.payload);
      else state.products = [action.payload];
    },
    deleteProductFromBasket(state, action: PayloadAction<number>) {
      if (!state.products) return;
      state.products = state.products.filter(product => product.id !== action.payload);
    },
  }
});

export const basketActions = basketSlice.actions;
export default basketSlice.reducer;

P.S.

  1. My user data is stored in user.slice.
  2. According to my plan, 'basketItem' will only store the item ID and its quantity
  3. If creating a separate entry for each user turns out to be too complex, please advise on how to store all items in a single local storage entry but filter out unnecessary ones on the client side.

Solution

  • Redux-Persist expects the storage key to be static. The problem you have is that you could use a dynamically computed key, but the key value you want/need is stored in the Redux store you are persisting. Since the user baskets are the only values being persisted I would suggest a more manual and localized persistence of the basket state where you can dynamically persist multiple baskets.

    Add an id property to the IBasketSlice interface:

    interface IBasketSlice {
      id: string;
      products: Product[] | null;
    }
    

    Update the basket slice to respond to auth changes to set a local id for the current basket you want to interact with, and to persist/load the products array to/from localStorage:

    import { createSlice } from '@reduxjs/toolkit';
    import { auth, checkAuth, logout } from '../path/to/user.actions.ts';
    
    const initialState: IBasketSlice = {
      id: "",
      products: null,
    };
     
    const basketSlice = createSlice({
      name: 'basket',
      initialState,
      reducers: {
        addProductToBasket(state, action: PayloadAction<IBasketItem>) {
          if (state.products) {
            state.products.push(action.payload);
          } else {
            state.products = [action.payload];
          }
    
          if (state.id) {
            localStorage.setItem(
              `basket/${state.id}`,
              JSON.stringify(state.products)
            );
          }
        },
        deleteProductFromBasket(state, action: PayloadAction<number>) {
          if (!state.products) return;
    
          state.products = state.products.filter(
            product => product.id !== action.payload
          );
    
          if (state.id) {
            localStorage.setItem(
              `basket/${state.id}`,
              JSON.stringify(state.products)
            );
          }
        },
      },
      extraReducers: (builder) => {
        const resetBasket = (state) => {
          state.id ="";
          state.products = null;
        };
    
        const setBasket = (state, action) => {
          const { id } = action.payload.user;
          state.id = id;
          state.products = 
            JSON.parse(localStorage.getItem(`basket/${id}`)) ?? [] as Product[];
        };
    
        builder
          .addCase(auth.fulfilled, setBasket)
          .addCase(checkAuth.fulfilled, setBasket)
          .addCase(auth.rejected, resetBasket)
          .addCase(logout.fulfilled, resetBasket);
      }
    });
    

    I haven't tested this in a running sandbox or anything so please do double-check the Typescript typings/syntax/etc since I'm only using a plain text editor here.