110: Client State Architecture and Redux Toolkit
Learning objectives
You will learn to:
- classify state before choosing a library;
- understand why server state stays in TanStack Query v5;
- identify when Context is sufficient;
- use Redux Toolkit instead of legacy hand-written Redux setup;
- create slices and selectors;
- normalize complex client state where needed;
- understand thunk and listener middleware responsibilities;
- compare Redux Toolkit with lighter stores such as Zustand/Jotai conceptually;
- test store behavior without coupling components to implementation details.
First classify the state
Before Redux, ask what kind of state you have.
local UI shared feature client state URL state server state form draft external browser/store state
A library should solve a real ownership/subscription problem.
Examples:
| State | Preferred owner |
|---|---|
| modal open | local component |
| task filter in URL | React Router |
| task API records | TanStack Query v5 |
| form field drafts | form/RHF |
| theme | Context often enough |
| cross-feature workflow state | Redux Toolkit may fit |
Do not move server tasks into Redux merely because Redux is “global.”
Why Redux Toolkit
Use Redux Toolkit (RTK), not legacy manual Redux boilerplate.
Install:
npm install @reduxjs/toolkit react-redux
Store:
import {
configureStore,
} from '@reduxjs/toolkit';
import uiReducer
from '../features/ui/uiSlice';
export const store =
configureStore({
reducer: {
ui: uiReducer,
},
});
Provider:
import {
Provider,
} from 'react-redux';
createRoot(root).render(
<Provider store={store}>
<App />
</Provider>,
);
Slice
import {
createSlice,
} from '@reduxjs/toolkit';
const uiSlice =
createSlice({
name: 'ui',
initialState: {
sidebarOpen: false,
density:
'comfortable',
selectedTaskIds:
[],
},
reducers: {
sidebarToggled(
state,
) {
state.sidebarOpen =
!state.sidebarOpen;
},
densityChanged(
state,
action,
) {
state.density =
action.payload;
},
taskSelectionToggled(
state,
action,
) {
const id =
action.payload;
const index =
state
.selectedTaskIds
.indexOf(id);
if (index >= 0) {
state
.selectedTaskIds
.splice(
index,
1,
);
} else {
state
.selectedTaskIds
.push(id);
}
},
},
});
export const {
sidebarToggled,
densityChanged,
taskSelectionToggled,
} =
uiSlice.actions;
export default
uiSlice.reducer;
RTK uses Immer so this reducer-style mutation syntax produces immutable updates.
Do not copy this syntax into plain useReducer; ordinary reducers still need immutable return values.
Selectors
export const
selectSidebarOpen =
(state) =>
state.ui
.sidebarOpen;
export const
selectDensity =
(state) =>
state.ui
.density;
Use:
const density =
useSelector(
selectDensity,
);
A selector documents the store contract and can hide internal shape.
Keep selectors focused
Avoid:
const ui =
useSelector(
(state) =>
state.ui,
);
when the component only needs one boolean.
A broad selection causes the component to rerender whenever any selected object reference changes.
Derived state
Do not store:
selectedTaskCount
if it can be derived:
export const
selectSelectedTaskCount =
(state) =>
state.ui
.selectedTaskIds
.length;
The same state-design rules from React still apply.
Async workflows
Redux Toolkit includes thunk middleware by default.
A thunk can orchestrate client-side workflows, but in this course server data fetching remains TanStack Query v5.
Use a thunk for a workflow such as:
read current client state perform a service action dispatch several local-state transitions
Do not build a second server cache in thunks if TanStack Query already owns that resource.
Listener middleware
Listener middleware responds to Redux actions/state changes.
Conceptually:
const listenerMiddleware =
createListenerMiddleware();
listenerMiddleware
.startListening({
actionCreator:
densityChanged,
effect: async (
action,
) => {
localStorage
.setItem(
'density',
action.payload,
);
},
});
Add to store:
const store =
configureStore({
reducer: {
ui: uiReducer,
},
middleware:
(
getDefaultMiddleware,
) =>
getDefaultMiddleware()
.prepend(
listenerMiddleware
.middleware,
),
});
This can be cleaner than putting global persistence side effects into React components.
Do not use middleware for everything. Feature-local synchronization may remain a Hook/Effect.
Redux versus Context
Context + reducer is strong when:
- state belongs to one subtree;
- updates are moderate;
- subscription granularity is simple.
Redux Toolkit becomes attractive when:
- many distant features coordinate;
- selector subscriptions matter;
- middleware is useful;
- DevTools/action history is valuable;
- state exists outside one component subtree.
Redux versus Zustand/Jotai
You should know the ecosystem choices without learning every API at once.
Zustand
Often selected for:
- small external store;
- selector-style subscriptions;
- minimal ceremony.
Jotai
Often selected for:
- atomic state composition;
- dependency graph between atoms.
Redux Toolkit
Strong for:
- explicit events;
- predictable architecture;
- mature middleware/DevTools;
- large team conventions.
Do not choose based on “fewest lines in hello world.” Choose based on state model, debugging needs, team constraints, and ecosystem.
Server state boundary
This is a key course rule:
TanStack Query v5 owns server cache
Redux may own:
selected task IDs global UI preferences multi-step client workflow state
Do not duplicate:
tasks from /api/tasks
into both stores.
Example integration
function TaskToolbar() {
const selectedIds =
useSelector(
(state) =>
state.ui
.selectedTaskIds,
);
const tasksQuery =
useQuery({
queryKey: ['tasks'],
queryFn: getTasks,
});
const selectedTasks =
tasksQuery.data
?.tasks
.filter((task) =>
selectedIds.includes(
task.id,
),
)
?? [];
return (
<p>
Selected:
{' '}
{
selectedTasks
.length
}
</p>
);
}
Redux owns selection IDs.
TanStack Query owns task records.
The UI derives their intersection.
Store testing
Test reducer/selector behavior directly:
test(
'toggles sidebar',
() => {
const state =
uiReducer(
undefined,
sidebarToggled(),
);
expect(
state.sidebarOpen,
).toBe(true);
},
);
For component tests, prefer user behavior over asserting internal dispatched action counts.
Common mistakes
- legacy
createStoresetup as the primary teaching path; - putting all API data in Redux;
- one giant slice;
- selecting entire store objects;
- duplicating derived state;
- using global state for local form drafts;
- installing several state libraries in one application without clear ownership.
Exercises
- Classify ten values by owner before installing Redux.
- Build an RTK UI slice.
- Add selectors for individual fields.
- Persist one preference with listener middleware.
- Keep tasks in TanStack Query while Redux owns selected IDs.
- Compare Context, Redux Toolkit, Zustand, and Jotai for one scenario.
Exit questions
- Why is state classification more important than library choice?
- Why does this course keep server state in TanStack Query?
- What does
createSliceprovide? - What problem do selectors solve?
- When does Context remain sufficient?
- What is listener middleware useful for?
Official references
- https://redux-toolkit.js.org/introduction/getting-started
- https://redux-toolkit.js.org/api/configureStore
- https://redux-toolkit.js.org/api/createSlice
- https://redux-toolkit.js.org/api/createListenerMiddleware
- https://react-redux.js.org/
Deep dive: global client state is a subscription architecture problem
A state library is not valuable because "many components can access variables."
React Context already delivers values deeply.
An external store becomes useful when you need a richer subscription/coordination model:
component A subscribes only to sidebarOpen component B subscribes only to selectedIds middleware observes a domain event DevTools records transitions store exists outside route subtree
Redux Toolkit is one structured solution.
Store design around domains
Avoid:
store/ everythingSlice.js
Prefer feature ownership:
features/ ├─ workspace/ │ └─ workspaceSlice.js ├─ preferences/ │ └─ preferencesSlice.js └─ selection/ └─ selectionSlice.js
But do not make a slice per component.
A slice is a cohesive state domain.
Slice state should be serializable
Redux DevTools/persistence/middleware work best with serializable state.
Avoid storing:
DOM nodes class instances Promises AbortControllers WebSocket objects functions
in Redux state.
Those belong in refs/services/external resources.
Typical state:
{
density: 'compact',
selectedTaskIds: ['t1', 't2'],
sidebarOpen: true
}
Immer semantics
Redux Toolkit's createSlice reducer receives an Immer draft.
This:
state.sidebarOpen = !state.sidebarOpen;
is safe inside RTK reducer because Immer produces an immutable next state.
Outside RTK/Immer, do not assume mutation syntax is safe.
Payload preparation
A slice can prepare payloads:
const notificationsSlice = createSlice({
name: 'notifications',
initialState: [],
reducers: {
notificationAdded: {
reducer(state, action) {
state.push(action.payload);
},
prepare(message, tone = 'info') {
return {
payload: {
id: crypto.randomUUID(),
message,
tone,
createdAt: Date.now(),
},
};
},
},
},
});
Use preparation for consistent action payloads, not hidden server requests.
Selectors and referential stability
Bad selector:
const selected = useSelector((state) =>
state.tasks.filter((task) => task.selected),
);
This returns a new array whenever selector runs, potentially rerendering.
For expensive derived client-state selectors, use memoized selectors such as createSelector.
But if tasks are actually server state in TanStack Query, do not move them to Redux just to demonstrate selector memoization.
Example for client selection:
const selectSelectedIds = (state) => state.selection.ids;
const selectSelectedCount = createSelector(
[selectSelectedIds],
(ids) => ids.length,
);
Normalized client entities
RTK createEntityAdapter can manage normalized collections for client-owned entities.
Use when Redux genuinely owns those entities.
Do not duplicate TanStack Query entities into an adapter.
Example use:
local workflow nodes not persisted on server yet offline editing drafts client-only canvas objects
depending on product architecture.
Thunks
Redux Toolkit's thunk middleware is useful for orchestration:
export const exportSelectedTasks =
() => async (dispatch, getState, services) => {
const ids = selectSelectedIds(getState());
dispatch(exportStarted());
try {
await services.exporter.export(ids);
dispatch(exportSucceeded());
} catch (error) {
dispatch(exportFailed(error.message));
}
};
For ordinary CRUD server-state caching, TanStack Query is the preferred owner in this course.
Dependency injection for thunks/listeners
Production code is easier to test when services are injected rather than imported as hidden global singletons.
Redux middleware configuration can provide extra dependencies.
This is an advanced architecture topic; use where testability/large-team boundaries justify it.
Listener middleware deep dive
Listener middleware can react to domain actions:
listenerMiddleware.startListening({
actionCreator: densityChanged,
effect: async (action, listenerApi) => {
await preferencesStorage.save({
density: action.payload,
});
},
});
Useful for:
- persistence;
- analytics;
- cross-slice workflows;
- debounced background actions.
Be careful not to recreate a tangled event bus.
Every listener should have a clear owner and test.
Cancellation in listener workflows
Listener middleware provides cancellation primitives for long-running workflows.
For example, auto-save:
draft changed → cancel previous debounce → wait → persist latest draft
This can be clearer than a component Effect when the workflow is global client-state behavior.
Do not use it for query-cache writes already handled by TanStack Query.
Store subscriptions and React rendering
useSelector subscribes component to selected output.
A component selecting:
state
subscribes to everything.
A component selecting:
state.preferences.density
has narrower change sensitivity.
Selector design is part of UI performance architecture.
Client state and URL state
Do not place:
current page status filter sort search query
in Redux if URL should own them.
Redux can still derive behavior from router state if necessary, but do not maintain two authoritative copies.
Client state and server state
Canonical rule in this curriculum:
remote resources → TanStack Query v5 cross-feature client workflow → Redux Toolkit when justified
Examples:
Query:
task records user profile from API permissions response server comments
Redux:
selected row IDs sidebar preference client-only workflow wizard global command palette state
Not absolute in every product, but a strong default.
Redux DevTools as reasoning tool
DevTools can show:
action previous state next state
This is especially useful when domain actions are meaningful.
Weak action log:
setValue setValue setValue
Strong:
selection/toggled workspace/layoutChanged export/started
Event vocabulary improves debugging.
Persistence
Persist only state that should survive reload.
Do not persist:
- stale auth tokens casually;
- entire server query data into Redux;
- transient modal state;
- huge unsanitized form data.
Version persisted state and plan migrations if app schema changes.
A small preference:
density=compact
is a good persistence candidate.
Hydration considerations
For SSR, initial Redux state can be server-provided.
Avoid server/client mismatch and cross-request store leakage.
A server-rendered app must not share one mutable Redux store across all users/requests.
Create appropriate request-scoped state as framework documentation requires.
Comparing external stores
Redux Toolkit
Strengths:
- explicit actions;
- ecosystem;
- DevTools;
- middleware;
- strong conventions;
- selector subscriptions.
Zustand
Strengths:
- compact external store;
- direct selector subscriptions;
- low ceremony.
Risks:
- architecture can become ad hoc without team conventions.
Jotai
Strengths:
- atomic composition;
- fine-grained dependencies.
Risks:
- domain flow can be distributed across many atoms if poorly designed.
Do not teach library choice as ranking. Match architecture.
Failure clinic
Everything global
Hard ownership, broad coupling.
Redux server cache + Query server cache
Two truths.
Nonserializable DOM node in store
Breaks tooling/serialization assumptions.
Broad selector
Unnecessary rerenders.
Middleware as hidden business logic network
Hard to discover/test.
Exercises
- Classify 20 app values by state owner.
- Design three cohesive slices.
- Create a memoized selector for client-owned derived state.
- Persist a preference via listener middleware.
- Implement debounced autosave listener with cancellation.
- Inspect action vocabulary in DevTools and rename setter-like events.
- Explain why API tasks stay in TanStack Query.
- Compare Redux Toolkit/Zustand/Jotai for one concrete app.
Mastery check
Explain:
- why external stores are about subscriptions/coordination;
- serializable state;
- Immer reducer semantics;
- selectors;
- listener middleware;
- server/client/URL ownership boundaries;
- framework SSR store scoping.
Production case study: client-state store without server duplication
Requirements:
- orders come from API;
- user can multi-select rows;
- selection survives navigation within
/orders; - toolbar shows selected order total;
- filters are shareable.
Architecture:
orders → TanStack Query filters → URL selected IDs → Redux Toolkit total → derived from Query data + selected IDs
Selector:
const selectedIds = useSelector(selectSelectedOrderIds);
const ordersQuery = useQuery({
queryKey: orderKeys.list(filters),
queryFn: ...
});
const selectedOrders = useMemo(
() =>
ordersQuery.data?.orders.filter((order) =>
selectedIds.includes(order.id),
) ?? [],
[ordersQuery.data, selectedIds],
);
Do not dispatch fetched orders into Redux.
If an order disappears from current query because of filter/page, decide whether selection should:
- remain by ID;
- clear;
- display "selected outside current page."
That is client workflow policy.
This architecture keeps each owner focused and prevents a giant store from becoming a second database.
Additional depth: Redux Toolkit async alternatives and why RTK Query is not used here
Redux Toolkit includes RTK Query, a capable server-data fetching/cache solution.
This course deliberately standardizes server state on TanStack Query v5+ to avoid teaching two competing cache owners for the same responsibility.
You should still know RTK Query exists because real Redux codebases may use it.
Architecture rule for this course:
Redux Toolkit → client-owned global/workflow state
TanStack Query v5+ → server cache
Do not combine TanStack Query and RTK Query for the same resource unless migrating between systems with a clear plan.
Store modules should not import React
A slice can be plain state logic:
// selectionSlice.js
without importing component Hooks.
React integration belongs in:
useSelector useDispatch Provider
This keeps store logic testable outside rendering.
Typed projects
In TypeScript, define typed hooks such as:
useAppDispatch useAppSelector
so components receive store-aware types.
The JavaScript mental model remains the same:
dispatch event store transition selector subscription render
