Skip to content

State Management with Redux

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
  • 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
  • Boilerplate reduction: createSlice combines actions and reducers
  • Async handling: createAsyncThunk manages loading states automatically
  • Immutability: Immer enables mutable-looking updates
  • DevTools: Time-travel debugging and action inspection
  • TypeScript: First-class type inference
  • 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
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 invitations

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 groups
export 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;
src/utils/redux/root-reducer.ts
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.

src/utils/redux/store.ts
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;
src/utils/redux/redux-provider.tsx
'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>
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 initialState

User navigates to home page:

// Component dispatches async thunk
const 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 toast

For immediate UI feedback (e.g., adding item to wishlist):

// Dispatch synchronous action immediately
dispatch(addItemToWishlist({ wishlistId, item }));
// Then call backend
try {
await itemHandlers.createItem(wishlistId, item);
} catch (error) {
// Rollback on error
dispatch(removeItemFromWishlist({ wishlistId, itemId: item.id }));
toast.error('Failed to add item');
}

When updating a single group within the list:

src/utils/refresh-one-group-array.ts
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
);
});

DO:

  • Use createAsyncThunk for 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)

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 needed
function DeepChild() {
const groups = useSelector((state) => state.groups.userGroups);
// ...
}

Avoid duplicate fetching:

// ❌ Bad: Fetch on every render
useEffect(() => {
dispatch(fetchUserGroups());
});
// ✅ Good: Fetch once or when dependency changes
useEffect(() => {
if (userGroups.length === 0) {
dispatch(fetchUserGroups());
}
}, [dispatch, userGroups.length]);
  • Network errors: createAsyncThunk automatically catches and dispatches rejected action
  • Logging: Sentry captures exceptions in thunks via global error boundary
  • User feedback: Display toast notifications in rejected case handlers
.addCase(fetchUserGroups.rejected, (state, action) => {
state.loading = false;
toast.error('Failed to load groups. Please try again.');
Sentry.captureException(action.error);
});
  • Rehydration delay: PersistGate blocks render until state is restored (~50-100ms)
  • Large state: Avoid storing large binary data (images) in Redux; use URLs instead
  • Selector memoization: Use reselect for 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)
);
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);
});
});
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);
});
  • 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

See Extensibility section in Overview for full guide.

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 selectors
export const {
selectById: selectItemById,
selectAll: selectAllItems,
} = itemsAdapter.getSelectors((state: RootState) => state.items);

Integrate WebSocket messages with Redux:

// In WebSocketProvider
const 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;
}
};

Use redux-undo wrapper around slices:

import undoable from 'redux-undo';
const rootReducer = combineReducers({
groups: undoable(groupsReducer),
// ...
});
// Dispatch undo/redo actions
dispatch(ActionCreators.undo());
dispatch(ActionCreators.redo());