Module: React and Ecosystem
React and Ecosystem·110·8 MIN READ

110: Client State Architecture and Redux Toolkit

TOPICS COVERED: 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.

text
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:

StatePreferred owner
modal openlocal component
task filter in URLReact Router
task API recordsTanStack Query v5
form field draftsform/RHF
themeContext often enough
cross-feature workflow stateRedux 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:

bash
npm install @reduxjs/toolkit react-redux

Store:

jsx
import {
  configureStore,
} from '@reduxjs/toolkit';

import uiReducer
  from '../features/ui/uiSlice';

export const store =
  configureStore({
    reducer: {
      ui: uiReducer,
    },
  });

Provider:

jsx
import {
  Provider,
} from 'react-redux';

createRoot(root).render(
  <Provider store={store}>
    <App />
  </Provider>,
);

Slice

jsx
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

jsx
export const
  selectSidebarOpen =
    (state) =>
      state.ui
        .sidebarOpen;

export const
  selectDensity =
    (state) =>
      state.ui
        .density;

Use:

jsx
const density =
  useSelector(
    selectDensity,
  );

A selector documents the store contract and can hide internal shape.

Keep selectors focused

Avoid:

jsx
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:

text
selectedTaskCount

if it can be derived:

jsx
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:

text
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:

jsx
const listenerMiddleware =
  createListenerMiddleware();

listenerMiddleware
  .startListening({
    actionCreator:
      densityChanged,

    effect: async (
      action,
    ) => {
      localStorage
        .setItem(
          'density',
          action.payload,
        );
    },
  });

Add to store:

jsx
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:

text
TanStack Query v5
owns server cache

Redux may own:

text
selected task IDs
global UI preferences
multi-step client workflow state

Do not duplicate:

text
tasks from /api/tasks

into both stores.

Example integration

jsx
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:

jsx
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 createStore setup 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

  1. Classify ten values by owner before installing Redux.
  2. Build an RTK UI slice.
  3. Add selectors for individual fields.
  4. Persist one preference with listener middleware.
  5. Keep tasks in TanStack Query while Redux owns selected IDs.
  6. Compare Context, Redux Toolkit, Zustand, and Jotai for one scenario.

Exit questions

  1. Why is state classification more important than library choice?
  2. Why does this course keep server state in TanStack Query?
  3. What does createSlice provide?
  4. What problem do selectors solve?
  5. When does Context remain sufficient?
  6. What is listener middleware useful for?

Official references


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:

text
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:

text
store/
  everythingSlice.js

Prefer feature ownership:

text
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:

text
DOM nodes
class instances
Promises
AbortControllers
WebSocket objects
functions

in Redux state.

Those belong in refs/services/external resources.

Typical state:

jsx
{
  density: 'compact',
  selectedTaskIds: ['t1', 't2'],
  sidebarOpen: true
}

Immer semantics

Redux Toolkit's createSlice reducer receives an Immer draft.

This:

jsx
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:

jsx
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:

jsx
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:

jsx
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:

text
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:

jsx
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:

jsx
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:

text
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:

jsx
state

subscribes to everything.

A component selecting:

jsx
state.preferences.density

has narrower change sensitivity.

Selector design is part of UI performance architecture.

Client state and URL state

Do not place:

text
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:

text
remote resources → TanStack Query v5
cross-feature client workflow → Redux Toolkit when justified

Examples:

Query:

text
task records
user profile from API
permissions response
server comments

Redux:

text
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:

text
action
previous state
next state

This is especially useful when domain actions are meaningful.

Weak action log:

text
setValue
setValue
setValue

Strong:

text
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:

text
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

  1. Classify 20 app values by state owner.
  2. Design three cohesive slices.
  3. Create a memoized selector for client-owned derived state.
  4. Persist a preference via listener middleware.
  5. Implement debounced autosave listener with cancellation.
  6. Inspect action vocabulary in DevTools and rename setter-like events.
  7. Explain why API tasks stay in TanStack Query.
  8. 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:

text
orders       → TanStack Query
filters      → URL
selected IDs → Redux Toolkit
total        → derived from Query data + selected IDs

Selector:

jsx
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:

text
Redux Toolkit
→ client-owned global/workflow state
text
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:

js
// selectionSlice.js

without importing component Hooks.

React integration belongs in:

text
useSelector
useDispatch
Provider

This keeps store logic testable outside rendering.

Typed projects

In TypeScript, define typed hooks such as:

ts
useAppDispatch
useAppSelector

so components receive store-aware types.

The JavaScript mental model remains the same:

text
dispatch event
store transition
selector subscription
render