Three Redux Toolkit slices replace the Zustand store for global client-state:
| Slice | Key idea |
|---|---|
sidebarSlice |
Immer mutation inside createSlice reducer |
filtersSlice |
PayloadAction<T> generic for typed payloads |
recentlyViewedSlice |
Array manipulation with Immer — looks imperative, is immutable |
createSlice wraps your reducers with Immer's produce.
Inside a reducer you can write to state directly:
toggle: (state) => {
state.isCollapsed = !state.isCollapsed; // mutates a draft, not real state
},Immer intercepts the mutation and returns a new state object. This makes reducers concise without sacrificing immutability guarantees.
Import PayloadAction from @reduxjs/toolkit to add type information to
the action's payload:
setStatusFilter: (state, action: PayloadAction<ProjectStatus | 'all'>) => {
state.statusFilter = action.payload;
},Without the generic the payload is typed as unknown, causing downstream errors.
Instead of importing useSelector / useDispatch directly (which gives you
unknown / untyped dispatch), create bound versions once in hooks.ts:
// hooks.ts
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();Every component imports from hooks.ts — TypeScript knows the full state shape
automatically.
useSelector re-renders when the selected value changes by reference equality.
For primitive values (boolean, string) this is fine.
For objects you need shallowEqual or createSelector (from reselect):
// Primitive — no extra comparator needed
const isCollapsed = useAppSelector((s) => s.sidebar.isCollapsed);
// Object — use shallowEqual or derive separate primitives
import { shallowEqual } from 'react-redux';
const { statusFilter, sortOrder } = useAppSelector(
(s) => s.filters,
shallowEqual,
);For derived / computed values, memoize with createSelector (re-exported by RTK):
import { createSelector } from '@reduxjs/toolkit';
const selectFilteredProjects = createSelector(
[(s: RootState) => s.filters.statusFilter],
(filter) => (projects: Project[]) =>
filter === 'all' ? projects : projects.filter(p => p.status === filter),
);The selector only recomputes when its inputs change.
| Redux Toolkit | Zustand | |
|---|---|---|
| Boilerplate | More (slice file per concern) | Minimal (one create() call) |
| DevTools | Excellent — time-travel, action log | Good — limited time-travel |
| Provider | Required (<Provider store={store}>) |
Not required |
| Async | createAsyncThunk or middleware |
Direct in action / external hook |
| TypeScript | Verbose but fully typed | Concise and fully typed |
| Scale | Scales well in large teams | Better for small/medium apps |
| Learning curve | Steeper | Gentler |
Both are valid choices. Zustand wins on simplicity; Redux wins on tooling and standardisation in large codebases.
- Open Chrome DevTools → Redux tab
- Every dispatched action appears in the left panel with its type and payload
- Click any action to "jump" the UI back to that point in time
- Use the slider at the bottom to scrub through history
- The Diff tab shows exactly which parts of state changed
This makes debugging complex state flows dramatically easier compared to console logs.
src/store/redux/sidebarSlice.ts — reducers completed
src/store/redux/filtersSlice.ts — reducers completed
src/store/redux/recentlyViewedSlice.ts — created from scratch
src/store/redux/store.ts — reducers registered
src/store/redux/hooks.ts — withTypes<> pattern
src/App.tsx — <Provider store={store}>
src/components/Layout.tsx — useAppSelector/Dispatch
src/pages/ProjectsLayout.tsx — useAppSelector/Dispatch
src/pages/ProjectDetailPanel.tsx — useAppDispatch + addRecentlyViewed
Zustand files (useAppStore.ts, selectors.ts) are removed in solution/ because
they are no longer used; they remain in start/ for comparison.