State Management with Redux
State Management with Redux
Section titled “State Management with Redux”Problem and Rationale
Section titled “Problem and Rationale”RednGift manages complex client-side state including:
- User’s groups and memberships
- Multiple wishlists with nested items
- Real-time chat messages
- Suggested trending items
- User contact list for invitations
Context and Constraints
Section titled “Context and Constraints”- Data relationships: Groups contain members, wishlists, and matches (many-to-many)
- Offline resilience: Users should see cached data on reload
- Performance: Avoid prop drilling through deeply nested components
- Consistency: Single source of truth for shared data (e.g., group list)
- Developer experience: Predictable state updates and debugging
Why Redux Toolkit?
Section titled “Why Redux Toolkit?”- Boilerplate reduction:
createSlicecombines actions and reducers - Async handling:
createAsyncThunkmanages loading states automatically - Immutability: Immer enables mutable-looking updates
- DevTools: Time-travel debugging and action inspection
- TypeScript: First-class type inference
Why Redux Persist?
Section titled “Why Redux Persist?”- User experience: Instant load of cached groups/wishlists on page refresh
- Offline capability: Read-only access to cached data without network
- Session continuity: Preserve scroll position and UI state
Design
Section titled “Design”Store Structure
Section titled “Store Structure”Redux Store (persisted to localStorage)├── general # UI state (modals, filters, selected group)├── groups # User's groups with members and invitations├── wishlists # User's personal wishlists├── items # Items within wishlists├── suggestedItems # Trending items from backend├── groupWishlists # Wishlists shared within a group├── userChats # Chat messages (blacklisted from persistence)└── userContacts # Phone contacts for invitationsSlice Pattern
Section titled “Slice Pattern”Each domain entity has a dedicated slice:
// src/utils/redux/slices/groupsSlice.ts (simplified)import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
export interface GroupInterface { loading: boolean; userGroups: Event[];}
const initialState: GroupInterface = { loading: false, userGroups: [],};
// Async thunk for fetching groupsexport const fetchUserGroups = createAsyncThunk( 'data/fetch/groups', async () => { const response = await groupsHandlers.getUserGroups(); return response; });
export const userGroupsSlice = createSlice({ name: 'userGroups', initialState, reducers: { set_userGroups: (state, action: PayloadAction<Event[]>) => { state.userGroups = action.payload; }, }, extraReducers: (builder) => { builder .addCase(fetchUserGroups.pending, (state) => { state.loading = true; }) .addCase(fetchUserGroups.fulfilled, (state, action) => { state.loading = false; state.userGroups = action.payload; }) .addCase(fetchUserGroups.rejected, (state) => { state.loading = false; }); },});
export const { set_userGroups } = userGroupsSlice.actions;export default userGroupsSlice.reducer;Root Reducer and Persistence
Section titled “Root Reducer and Persistence”import { combineReducers } from 'redux';import { persistReducer } from 'redux-persist';import createWebStorage from 'redux-persist/lib/storage/createWebStorage';
const storage = typeof window !== 'undefined' ? createWebStorage('local') : createNoopStorage(); // SSR-safe
const persistConfig = (keyName: string, blacklist?: string[]) => ({ key: keyName, storage, keyPrefix: 'redux-', blacklist: blacklist || [],});
export const rootReducer = combineReducers({ general: persistReducer(persistConfig('general'), generalReducer), groups: persistReducer(persistConfig('groups'), groupsReducer), wishlists: persistReducer(persistConfig('wishlist'), wishlistsReducer), items: persistReducer(persistConfig('items'), itemsReducer), suggestedItems: persistReducer(persistConfig('suggestedItems'), suggestedItemsReducer), groupWishlists: persistReducer(persistConfig('groupWishlists'), groupWishlistReducer), userChats: persistReducer(persistConfig('userChats', ['userChats']), userChatReducer), userContacts: persistReducer(persistConfig('userContacts'), userContactsReducer),});Note: userChats slice blacklists its own key from persistence to avoid stale messages.
Store Configuration
Section titled “Store Configuration”import { configureStore } from '@reduxjs/toolkit';import { persistStore, persistReducer } from 'redux-persist';import { rootReducer } from './root-reducer';
export const store = configureStore({ reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], }, }),});
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof rootReducer>;export type AppDispatch = typeof store.dispatch;Provider Setup
Section titled “Provider Setup”'use client';
import { Provider } from 'react-redux';import { PersistGate } from 'redux-persist/integration/react';import { store, persistor } from './store';
export default function ReduxProvider({ children }: Props) { return ( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> {children} </PersistGate> </Provider> );}Wraps application in src/app/layout.tsx:
<AuthProvider> <ReduxProvider> {children} </ReduxProvider></AuthProvider>Lifecycle and Data Flow
Section titled “Lifecycle and Data Flow”1. Application Initialization
Section titled “1. Application Initialization”App mounts │ ▼ReduxProvider initializes store │ ▼PersistGate loads state from localStorage │ ├─ Rehydration successful ──────┐ │ │ │ ▼ │ Populate Redux store │ │ │ ▼ │ Render app with cached data │ └─ No cached state ──────┐ │ ▼ Render app with initialState2. Fetching and Caching Data
Section titled “2. Fetching and Caching Data”User navigates to home page:
// Component dispatches async thunkconst dispatch = useDispatch();
useEffect(() => { dispatch(fetchUserGroups());}, [dispatch]);Redux flow:
dispatch(fetchUserGroups()) │ ▼fetchUserGroups.pending │ ▼state.groups.loading = true │ ▼Call groupsHandlers.getUserGroups() │ ├─ Success ─────────────────────┐ │ │ │ ▼ │ fetchUserGroups.fulfilled │ │ │ ▼ │ state.groups.userGroups = payload │ state.groups.loading = false │ │ │ ▼ │ Redux persist writes to localStorage │ └─ Error ───────────────────────┐ │ ▼ fetchUserGroups.rejected │ ▼ state.groups.loading = false │ ▼ Show error toast3. Optimistic Updates
Section titled “3. Optimistic Updates”For immediate UI feedback (e.g., adding item to wishlist):
// Dispatch synchronous action immediatelydispatch(addItemToWishlist({ wishlistId, item }));
// Then call backendtry { await itemHandlers.createItem(wishlistId, item);} catch (error) { // Rollback on error dispatch(removeItemFromWishlist({ wishlistId, itemId: item.id })); toast.error('Failed to add item');}4. Normalized State Updates
Section titled “4. Normalized State Updates”When updating a single group within the list:
export function refreshOneGroupArray( groups: Event[], updatedGroup: Event, groupId: number): Event[] { return groups.map((group) => group.id === groupId ? updatedGroup : group );}
// Usage in slice.addCase(fetchOneGroup.fulfilled, (state, action) => { state.userGroups = refreshOneGroupArray( state.userGroups, action.payload.group, action.payload.group.id );});Implementation Notes
Section titled “Implementation Notes”Patterns
Section titled “Patterns”✅ DO:
- Use
createAsyncThunkfor all API calls to auto-manage loading states - Name thunks with domain prefix:
'groups/fetch','wishlists/create' - Export actions for direct dispatch when needed:
export const { set_userGroups } = slice.actions - Use selectors for derived state:
const activeGroups = useSelector(selectActiveGroups)
❌ DON’T:
- Don’t mutate state outside of reducers (Redux Toolkit uses Immer internally)
- Don’t store non-serializable data (functions, class instances) in Redux
- Don’t duplicate data across slices (normalize and reference by ID)
Anti-patterns
Section titled “Anti-patterns”Avoid prop drilling:
// ❌ Bad: Passing groups through 5 components<Parent groups={groups}> <Child groups={groups}> <GrandChild groups={groups}> <DeepChild groups={groups} />
// ✅ Good: Access state directly where neededfunction DeepChild() { const groups = useSelector((state) => state.groups.userGroups); // ...}Avoid duplicate fetching:
// ❌ Bad: Fetch on every renderuseEffect(() => { dispatch(fetchUserGroups());});
// ✅ Good: Fetch once or when dependency changesuseEffect(() => { if (userGroups.length === 0) { dispatch(fetchUserGroups()); }}, [dispatch, userGroups.length]);Error Handling
Section titled “Error Handling”- Network errors:
createAsyncThunkautomatically catches and dispatchesrejectedaction - Logging: Sentry captures exceptions in thunks via global error boundary
- User feedback: Display toast notifications in
rejectedcase handlers
.addCase(fetchUserGroups.rejected, (state, action) => { state.loading = false; toast.error('Failed to load groups. Please try again.'); Sentry.captureException(action.error);});Performance Considerations
Section titled “Performance Considerations”- Rehydration delay:
PersistGateblocks render until state is restored (~50-100ms) - Large state: Avoid storing large binary data (images) in Redux; use URLs instead
- Selector memoization: Use
reselectfor expensive computed state
import { createSelector } from '@reduxjs/toolkit';
const selectGroups = (state: RootState) => state.groups.userGroups;
export const selectActiveGroups = createSelector( [selectGroups], (groups) => groups.filter((g) => g.isMember));Testing Strategy
Section titled “Testing Strategy”Unit Tests (Reducers)
Section titled “Unit Tests (Reducers)”import groupsReducer, { fetchUserGroups } from './groupsSlice';
describe('groupsSlice', () => { it('should set loading on pending', () => { const state = groupsReducer(initialState, fetchUserGroups.pending); expect(state.loading).toBe(true); });
it('should populate groups on fulfilled', () => { const mockGroups = [{ id: 1, name: 'Test Group' }]; const state = groupsReducer( initialState, fetchUserGroups.fulfilled(mockGroups, '', undefined) ); expect(state.userGroups).toEqual(mockGroups); expect(state.loading).toBe(false); });});Integration Tests (Thunks)
Section titled “Integration Tests (Thunks)”import { configureStore } from '@reduxjs/toolkit';import groupsReducer, { fetchUserGroups } from './groupsSlice';
const createMockStore = () => configureStore({ reducer: { groups: groupsReducer } });
it('should fetch groups and update state', async () => { const mockGroups = [{ id: 1, name: 'Test' }]; jest.spyOn(groupsHandlers, 'getUserGroups').mockResolvedValue(mockGroups);
const store = createMockStore(); await store.dispatch(fetchUserGroups());
expect(store.getState().groups.userGroups).toEqual(mockGroups);});E2E Tests
Section titled “E2E Tests”- Verify persistence: reload page and check cached data renders
- Test optimistic updates: create item, refresh before backend responds
- Test error recovery: kill network, trigger fetch, verify error toast
Extending This Concept
Section titled “Extending This Concept”Adding a New Slice
Section titled “Adding a New Slice”See Extensibility section in Overview for full guide.
Adding Normalized State (Entity Adapter)
Section titled “Adding Normalized State (Entity Adapter)”For large lists with frequent lookups by ID, use createEntityAdapter:
import { createEntityAdapter } from '@reduxjs/toolkit';
const itemsAdapter = createEntityAdapter<Item>({ selectId: (item) => item.id, sortComparer: (a, b) => a.name.localeCompare(b.name),});
const itemsSlice = createSlice({ name: 'items', initialState: itemsAdapter.getInitialState(), reducers: { addItem: itemsAdapter.addOne, updateItem: itemsAdapter.updateOne, removeItem: itemsAdapter.removeOne, },});
// Auto-generated selectorsexport const { selectById: selectItemById, selectAll: selectAllItems,} = itemsAdapter.getSelectors((state: RootState) => state.items);Adding Real-time State Sync
Section titled “Adding Real-time State Sync”Integrate WebSocket messages with Redux:
// In WebSocketProviderconst handleMessage = (event: MessageEvent) => { const message = JSON.parse(event.data);
switch (message.type) { case 'GROUP_UPDATED': dispatch(fetchOneGroup(message.groupId)); break; case 'NEW_MESSAGE': dispatch(addChatMessage(message.payload)); break; }};Adding Undo/Redo
Section titled “Adding Undo/Redo”Use redux-undo wrapper around slices:
import undoable from 'redux-undo';
const rootReducer = combineReducers({ groups: undoable(groupsReducer), // ...});
// Dispatch undo/redo actionsdispatch(ActionCreators.undo());dispatch(ActionCreators.redo());Further Reading
Section titled “Further Reading”- API Layer and Data Fetching - How thunks call backend handlers
- Real-time Communication - WebSocket integration with Redux
- Secret Santa Groups - Groups slice in action
- Wishlist Management - Wishlists and items slices