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 reaching for Redux, first identify what kind of state you are dealing with. The ownership decision usually matters more than the library API.
local UI
shared feature client state
URL state
server state
form draft
external browser/store state
A state library should solve a real ownership or subscription problem. It should not be added simply because a value is used in more than one component.
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 just because Redux is “global.” The fact that several parts of the UI can read a value does not make Redux its correct owner.
Why Redux Toolkit
Use Redux Toolkit (RTK), not the legacy manual Redux boilerplate. RTK supplies the standard store configuration and reducer patterns while keeping the underlying Redux model explicit.
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>,
);
The store holds the Redux state tree. Provider makes that store available to the React subtree, and React-Redux Hooks connect components to it.
Slice
A slice groups a state domain with the actions and reducer logic that operate on it. Here is a small UI 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. The reducer is written against a draft, and Immer turns the draft changes into a new immutable state value.
Do not copy this syntax into plain useReducer; ordinary reducers still need to return immutable updates rather than mutating their existing state.
Selectors
Selectors are the read interface for a store domain. Start with small selectors that expose only the pieces a component needs:
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 the internal state shape. If the slice is reorganized later, consumers can keep using the selector instead of reaching into that shape everywhere.
Keep selectors focused
Avoid:
const ui =
useSelector(
(state) =>
state.ui,
);
when the component only needs one boolean. Selecting the whole ui object makes the component sensitive to changes that have nothing to do with its actual rendering needs.
A broad selection causes the component to rerender whenever any selected object reference changes. Focused selectors make the subscription boundary clearer and reduce unnecessary rendering work.
Derived state
Do not store:
selectedTaskCount
if it can be derived:
export const
selectSelectedTaskCount =
(state) =>
state.ui
.selectedTaskIds
.length;
Storing both the IDs and their count creates two values that can disagree. The same state-design rules from React still apply: keep the minimal source of truth and derive values when the computation is cheap and deterministic.
Async workflows
Redux Toolkit includes thunk middleware by default. A thunk can orchestrate a client-side workflow, 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
That is different from turning a thunk into a second query cache. Do not build a second server cache in thunks if TanStack Query already owns that resource.
Listener middleware
Listener middleware responds to Redux actions or state changes. It is useful when a cross-feature effect should be attached to a domain event rather than to one particular component.
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. The listener reacts to the action wherever it was dispatched, rather than requiring every component that changes density to remember the persistence behavior.
Do not use middleware for everything. Feature-local synchronization may remain a Hook/Effect when the behavior belongs naturally to one component or feature boundary.
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.
Context is not an inferior version of Redux. It is often the simpler and more appropriate owner for state with a narrow subtree and straightforward update behavior.
Redux versus Zustand/Jotai
You should understand the ecosystem choices without trying to learn every API at once. The useful comparison is architectural: how state is owned, how consumers subscribe, how events are observed, and how much convention the team needs.
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 the 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. One resource should have one authoritative cache owner. Otherwise, updates, invalidation, loading state, and stale data become harder to reason about.
Example integration
This component combines the two ownership models without copying server records into Redux:
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. If the query refreshes, the task records can change without requiring a second synchronization path in Redux.
Store testing
Test reducer and selector behavior directly:
test(
'toggles sidebar',
() => {
const state =
uiReducer(
undefined,
sidebarToggled(),
);
expect(
state.sidebarOpen,
).toBe(true);
},
);
This test checks the state transition without rendering a component. For component tests, prefer user behavior over asserting internal dispatched action counts. The action sequence is an implementation detail unless the sequence itself is the behavior under test.
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 merely because “many components can access variables.” React Context already delivers values deeply. The harder question is how components subscribe and how separate parts of the application coordinate changes.
An external store becomes useful when you need a richer subscription and 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 to that problem. It gives the application explicit transitions and a shared place to observe them, while selectors keep individual subscriptions narrow.
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, so its boundary should follow ownership and behavior rather than the current component tree.
Slice state should be serializable
Redux DevTools, persistence, and middleware work best when the state is serializable. That makes transitions inspectable and allows state to be logged, replayed, or persisted predictably.
Avoid storing:
DOM nodes
class instances
Promises
AbortControllers
WebSocket objects
functions
in Redux state.
Those belong in refs, services, or external resources. Keep the serializable description of the workflow in Redux, and keep the live browser or network object at the boundary that owns it.
Typical state:
{
density: 'compact',
selectedTaskIds: ['t1', 't2'],
sidebarOpen: true
}
Immer semantics
Redux Toolkit's createSlice reducer receives an Immer draft. That is why this code is safe inside an RTK reducer:
state.sidebarOpen = !state.sidebarOpen;
Immer records the draft mutation and produces an immutable next state. Outside RTK/Immer, do not assume mutation syntax is safe.
Payload preparation
A slice can prepare payloads before the reducer receives them. This is useful when every action needs the same generated fields:
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. Payload construction should remain easy to understand from the action itself; network work belongs in an explicit workflow layer.
Selectors and referential stability
Bad selector:
const selected = useSelector((state) =>
state.tasks.filter((task) => task.selected),
);
This returns a new array whenever the selector runs, potentially causing a rerender even when the underlying selection has not meaningfully changed.
For expensive derived client-state selectors, use memoized selectors such as createSelector. Memoization is useful when the inputs have not changed and the derived computation would otherwise allocate or calculate again.
But if tasks are actually server state in TanStack Query, do not move them to Redux just to demonstrate selector memoization. The ownership decision comes first.
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. Normalization can make updates and lookups predictable when the collection is genuinely part of client state.
Use it when Redux genuinely owns those entities.
Do not duplicate TanStack Query entities into an adapter. Doing so creates two stores that both claim authority over the same server records.
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. A thunk can read current client state, call an injected service, and describe the resulting local transitions:
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. A thunk may orchestrate a client workflow that uses a service, but it should not quietly become a replacement cache.
Dependency injection for thunks/listeners
Production code is easier to test when services are injected rather than imported as hidden global singletons. Tests can then provide a controlled exporter or storage implementation and verify the workflow without making real external calls.
Redux middleware configuration can provide extra dependencies.
This is an advanced architecture topic; use it where testability or large-team boundaries justify it. Do not introduce the extra indirection merely because it is possible.
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, and the action it observes should represent a meaningful domain event.
Cancellation in listener workflows
Listener middleware provides cancellation primitives for long-running workflows. For example, an autosave flow can be modeled as:
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. Cancellation matters because persisting every intermediate keystroke, or allowing older work to finish after newer work, can produce unnecessary work or stale writes.
Do not use it for query-cache writes already handled by TanStack Query.
Store subscriptions and React rendering
useSelector subscribes a component to its selected output. A component selecting:
state
subscribes to everything.
A component selecting:
state.preferences.density
has narrower change sensitivity. Selector design is therefore part of UI performance architecture, not just a style preference.
Client state and URL state
Do not place:
current page
status filter
sort
search query
in Redux if the URL should own them. URL-owned state is shareable, bookmarkable, and naturally integrated with navigation.
Redux can still derive behavior from router state if necessary, but do not maintain two authoritative copies. Two copies eventually diverge or require synchronization code that obscures which value is correct.
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
This is not absolute in every product, but it is a strong default. Start with one owner per category and make an exception only when the architecture has a clear reason.
Redux DevTools as reasoning tool
DevTools can show:
action
previous state
next state
That history is especially useful when domain actions are meaningful. Compare this weak action log:
setValue
setValue
setValue
with a more informative one:
selection/toggled
workspace/layoutChanged
export/started
Event vocabulary improves debugging because the log explains what the user or workflow did, not merely that some generic setter ran.
Persistence
Persist only state that should survive a reload. Persistence is a product decision as well as a technical one.
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 the app schema changes. A small preference such as:
density=compact
is a good persistence candidate. Server data and transient UI state generally are not.
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 or requests. That could allow one request's state to appear in another request.
Create appropriate request-scoped state as framework documentation requires.
Comparing external stores
Redux Toolkit: comparison details
Strengths:
- explicit actions;
- ecosystem;
- DevTools;
- middleware;
- strong conventions;
- selector subscriptions.
Zustand: comparison details
Strengths:
- compact external store;
- direct selector subscriptions;
- low ceremony.
Risks:
- architecture can become ad hoc without team conventions.
Jotai: comparison details
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 the tool to the architecture.
Failure clinic
Everything global
Hard ownership, broad coupling. When every value is global, it becomes difficult to tell which feature owns updates and why unrelated parts rerender.
Redux server cache + Query server cache
Two truths. The same remote record can be fresh in one place and stale in the other.
Nonserializable DOM node in store
Breaks tooling and serialization assumptions. Keep the live DOM resource outside Redux and store only the serializable state needed to describe the interaction.
Broad selector
Unnecessary rerenders. Select the smallest value that represents the component's needs.
Middleware as hidden business logic network
Hard to discover and test. Listeners need clear ownership and meaningful action boundaries.
Deep-dive 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
Consider a page with these 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. Redux owns the IDs, while TanStack Query remains responsible for fetching, caching, and updating the order records.
If an order disappears from the current query because of a filter or page, decide whether selection should:
- remain by ID;
- clear;
- display “selected outside current page.”
That is client workflow policy. It should be decided explicitly rather than being an accidental consequence of whichever array happens to be rendered.
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 and cache solution. This course deliberately standardizes server state on TanStack Query v5+ so learners do not have to reason about two competing cache owners for the same responsibility.
You should still know RTK Query exists because real Redux codebases may use it. The presence of that tool does not change this course's ownership rule.
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 you are 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 and preserves a clean boundary between state logic and the React adapter.
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
