FullStack Course LogoFullStack Course
Module: React and Ecosystem
React and Ecosystem·110·13 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 reaching for Redux, first identify what kind of state you are dealing with. The ownership decision usually matters more than the library API.

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

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

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>,
);

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:

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

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

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

text
selectedTaskCount

if it can be derived:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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. A thunk can read current client state, call an injected service, and describe the resulting local transitions:

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

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

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

jsx
state

subscribes to everything.

A component selecting:

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

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

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

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:

text
action
previous state
next state

That history is especially useful when domain actions are meaningful. Compare this weak action log:

text
setValue
setValue
setValue

with a more informative one:

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

text
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

  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

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:

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

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 you are 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 and preserves a clean boundary between state logic and the React adapter.

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
Reader page: /react/lesson/110/client-state-architecture-and-redux-toolkit