Build CoreOrdered learning track

Redux vs Zustand vs Context

Learn React Hooks, State Management, Component Composition, Context Passing, Component Communications & Orchestration - Part 065

Decision framework production-grade untuk memilih Context, Zustand, atau Redux Toolkit berdasarkan ownership, lifecycle, sharing radius, governance, observability, performance, server-state boundary, dan team scale.

12 min read2306 words
PrevNext
Lesson 65123 lesson track24–67 Build Core
#react#context#zustand#redux-toolkit+3 more

Part 065 — Redux vs Zustand vs Context

Banyak diskusi state management React berhenti di level selera:

Context itu cukup.
Redux itu terlalu berat.
Zustand itu lebih simple.

Kalimat seperti itu terlalu dangkal untuk codebase besar. Pertanyaan yang benar bukan “library mana paling bagus?”, tetapi:

State apa yang sedang kita kelola?
Siapa pemiliknya?
Siapa yang boleh mengubahnya?
Berapa luas radius pembacaannya?
Seberapa sering berubah?
Apakah perlu action log?
Apakah perlu selector granularity?
Apakah state ini server-owned, client-owned, workflow-owned, atau capability-owned?
Apa failure mode jika tool yang dipilih salah?

Context, Zustand, dan Redux Toolkit bukan tiga versi dari benda yang sama. Mereka menyelesaikan kelas masalah yang overlap, tetapi pusat gravitasinya berbeda.

Context       = dependency/capability passing untuk subtree.
Zustand       = lightweight external store dengan selector ergonomics.
Redux Toolkit = governed state architecture dengan reducer/action/middleware convention.

Kalau kamu memilih berdasarkan “lebih populer” atau “lebih sedikit boilerplate”, kamu belum memilih arsitektur. Kamu baru memilih ergonomi.

Arsitektur memilih berdasarkan invariant, failure mode, scale, dan maintenance cost.


1. First Principle: State Tool Is Ownership Tool

Setiap state management tool menjawab satu pertanyaan fundamental:

Di mana state hidup, dan siapa yang mengontrol perubahan state itu?

React local state menjawab:

State hidup di component instance.

Lifted state menjawab:

State hidup di closest owner yang mengoordinasi beberapa children.

Context menjawab:

Value disediakan oleh provider untuk subtree.

Zustand menjawab:

State hidup di external store yang dapat dibaca component melalui selector.

Redux Toolkit menjawab:

State hidup di centralized governed store, berubah lewat action/reducer/middleware convention.

Perbedaan ini kecil di aplikasi demo. Perbedaan ini besar di aplikasi production.

Diagram ownership:

Mental model penting:

Jangan mulai dari library.
Mulai dari ownership.

2. The Three Tools in One Sentence Each

Context

Context adalah mekanisme React untuk membuat value tersedia ke subtree tanpa prop drilling. Context paling kuat ketika value-nya adalah dependency, capability, configuration, atau low-frequency scoped state.

Contoh cocok:

- theme
- locale
- direction
- form field context
- modal service capability
- permission snapshot untuk subtree
- feature flag reader
- analytics capability
- dependency injection untuk testing

Context bukan selector store. Context update akan membuat consumer yang membaca context tersebut ikut masuk siklus render.


Zustand

Zustand adalah external store ringan dengan API hook-friendly. Ia cocok saat kamu butuh shared client state dengan selector subscription, sedikit ceremony, dan store kecil/menengah yang mudah dibaca.

Contoh cocok:

- UI workspace state
- selection state besar
- editor state client-owned
- command palette state
- modal/overlay registry
- client-side graph/layout state
- persisted user preference
- micro store per feature

Zustand kuat ketika tim butuh pragmatisme dan granularity, tetapi belum butuh governance Redux-level.


Redux Toolkit

Redux Toolkit adalah Redux modern dengan convention resmi: configureStore, createSlice, middleware, typed hooks, selector, entity adapter, async lifecycle, dan DevTools timeline. Ia cocok saat shared client state membutuhkan governance, action vocabulary, observability, middleware boundary, dan team-scale consistency.

Contoh cocok:

- large shared client state
- normalized client entity cache non-server-authoritative
- audit-relevant UI transitions
- cross-feature workflow state
- team-wide action/reducer convention
- debugging via action timeline
- deterministic state transition tests

Redux Toolkit bukan “global state untuk semua hal”. Ia adalah struktur untuk state yang benar-benar layak digovern.


3. Quick Decision Table

QuestionContextZustandRedux Toolkit
Perlu menghindari prop drilling untuk dependency?Sangat cocokBisa, tapi overkillBisa, tapi biasanya salah layer
State sering berubah dan banyak consumer?Hati-hatiCocokCocok
Perlu selector granular?Tidak nativeCocokCocok via selector/react-redux
Perlu action log/DevTools timeline kuat?TidakTerbatas/middlewareSangat cocok
Perlu middleware governance?TidakTerbatasSangat cocok
Perlu structure untuk banyak team?LemahSedangKuat
Perlu API minimal?KuatKuatSedang/lemah
Perlu normalized entity helpers?ManualManualKuat via entity adapter
Cocok untuk capability passing?Sangat cocokBisaJarang
Cocok untuk server-state cache?TidakTidak idealRTK Query bisa
Cocok untuk local component state?TidakBiasanya tidakTidak
Cocok untuk subtree-scoped provider state?BisaBisa scoped storeBisa, tapi berat
Debugging accidental global coupling?SulitSedangLebih mudah dengan actions

Rule of thumb:

Context untuk membaca dependency.
Zustand untuk shared client state yang butuh granular subscription tanpa governance besar.
Redux Toolkit untuk shared client state yang butuh convention, traceability, dan team governance.

4. The Dangerous Question: “Can Context Replace Redux?”

Pertanyaan ini sering muncul, tetapi framing-nya lemah.

Context dan Redux Toolkit overlap pada satu area:

shared state yang dibaca banyak component

Tetapi keduanya tidak memiliki kontrak yang sama.

Context memberikan:

Provider -> Consumer dependency propagation

Redux Toolkit memberikan:

Store -> Action -> Reducer -> Middleware -> Selector -> Subscriber

Jika masalahmu hanya:

Saya tidak mau prop drilling theme/locale/session/capability.

Context cukup.

Jika masalahmu:

Saya butuh action vocabulary, transition log, middleware, normalized entity state, dan multi-team convention.

Context bukan replacement Redux.

Context dapat membawa reducer. Tetapi reducer+context tetap tidak otomatis memberi:

- selector-level subscription native,
- middleware pipeline matang,
- DevTools action timeline sekuat Redux,
- ecosystem entity adapter,
- standard async lifecycle convention,
- team-wide state governance.

Reducer+Context adalah bagus untuk scoped complex state. Bukan pengganti otomatis untuk governed app state.


5. Context: When It Is the Right Tool

Gunakan Context saat value memenuhi minimal satu dari kondisi ini:

- Dibutuhkan oleh banyak descendant dalam subtree.
- Value adalah dependency/capability, bukan volatile data utama.
- Update frequency rendah atau provider scope kecil.
- Provider hierarchy punya arti arsitektural.
- Consumer memang bergantung pada environment tersebut.

Contoh production:

const PermissionContext = createContext<PermissionReader | null>(null);

export function PermissionProvider({
  permissions,
  children,
}: {
  permissions: PermissionSnapshot;
  children: React.ReactNode;
}) {
  const reader = useMemo(() => createPermissionReader(permissions), [permissions]);

  return (
    <PermissionContext.Provider value={reader}>
      {children}
    </PermissionContext.Provider>
  );
}

export function usePermission() {
  const reader = useContext(PermissionContext);
  if (!reader) {
    throw new Error('usePermission must be used inside PermissionProvider');
  }
  return reader;
}

Kenapa ini cocok?

Permission reader adalah capability.
Consumer tidak perlu tahu dari mana permission berasal.
Provider scope dapat berbeda per page/tenant/session.

Kapan mulai buruk?

type AppContextValue = {
  user: User | null;
  theme: Theme;
  selectedRows: string[];
  currentSearchText: string;
  notifications: Notification[];
  draftInvoice: InvoiceDraft;
  setSelectedRows: Dispatch<SetStateAction<string[]>>;
  setCurrentSearchText: Dispatch<SetStateAction<string>>;
  setDraftInvoice: Dispatch<SetStateAction<InvoiceDraft>>;
};

Ini bukan context. Ini dumping ground.

Masalahnya:

- domain tidak terkait masuk satu value,
- update kecil dapat memengaruhi banyak consumer,
- setter mentah bocor,
- ownership tidak jelas,
- testing butuh mock raksasa,
- refactor jadi mahal.

Context yang baik biasanya punya nama sempit:

ThemeContext
FieldContext
DialogContext
PermissionContext
AnalyticsContext
FormSectionContext

Context yang buruk biasanya punya nama terlalu luas:

AppContext
GlobalContext
SharedContext
CommonContext
StoreContext

6. Zustand: When It Is the Right Tool

Gunakan Zustand saat kamu butuh:

- shared client state,
- selector subscription,
- API kecil,
- store/action dekat dengan feature,
- tidak butuh action log governance berat,
- mudah membuat scoped store jika perlu,
- persistence middleware sederhana,
- subscription dari non-React code.

Contoh production: selection model besar.

import { create } from 'zustand';

interface SelectionStore {
  selectedIds: Set<string>;
  isSelected(id: string): boolean;
  select(id: string): void;
  deselect(id: string): void;
  toggle(id: string): void;
  clear(): void;
}

export const useSelectionStore = create<SelectionStore>((set, get) => ({
  selectedIds: new Set<string>(),

  isSelected: (id) => get().selectedIds.has(id),

  select: (id) =>
    set((state) => {
      const next = new Set(state.selectedIds);
      next.add(id);
      return { selectedIds: next };
    }),

  deselect: (id) =>
    set((state) => {
      const next = new Set(state.selectedIds);
      next.delete(id);
      return { selectedIds: next };
    }),

  toggle: (id) =>
    set((state) => {
      const next = new Set(state.selectedIds);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return { selectedIds: next };
    }),

  clear: () => set({ selectedIds: new Set() }),
}));

Consumer granular:

function Row({ id }: { id: string }) {
  const selected = useSelectionStore((state) => state.selectedIds.has(id));
  const toggle = useSelectionStore((state) => state.toggle);

  return (
    <button aria-pressed={selected} onClick={() => toggle(id)}>
      {selected ? 'Selected' : 'Select'}
    </button>
  );
}

Mengapa bukan Context?

Banyak row membaca bagian kecil dari state.
Perubahan selection satu row tidak seharusnya membuat semua consumer context membaca root value yang sama.
Selector subscription lebih natural.

Mengapa bukan Redux Toolkit?

Jika state ini feature-local, action log tidak audit-critical, dan tim tidak butuh convention Redux-level, Zustand cukup.

Zustand menjadi buruk ketika:

- semua feature membuat singleton store global tanpa boundary,
- action naming tidak konsisten,
- state server cache masuk store manual,
- persistence dipakai tanpa versioning/migration,
- selector mengembalikan object/array baru setiap render,
- store menjadi implicit dependency dari semua component.

Zustand itu simple. Tetapi simple tidak berarti bebas desain.


7. Redux Toolkit: When It Is the Right Tool

Gunakan Redux Toolkit saat shared state butuh governance.

Governance berarti:

- transisi state harus punya nama,
- event/action vocabulary harus konsisten,
- debugging butuh timeline,
- side effects butuh middleware boundary,
- reducer transition perlu dites,
- entity state perlu normalisasi,
- banyak team bergantung pada state yang sama,
- refactor perlu dilakukan dengan contract jelas.

Contoh slice:

import { createSlice, PayloadAction } from '@reduxjs/toolkit';

type BulkActionState =
  | { status: 'idle'; selectedIds: string[] }
  | { status: 'confirming'; selectedIds: string[]; operation: 'archive' | 'delete' }
  | { status: 'submitting'; selectedIds: string[]; operation: 'archive' | 'delete'; requestId: string }
  | { status: 'failed'; selectedIds: string[]; operation: 'archive' | 'delete'; message: string };

const initialState: BulkActionState = {
  status: 'idle',
  selectedIds: [],
};

const bulkActionSlice = createSlice({
  name: 'bulkAction',
  initialState,
  reducers: {
    selectionChanged(state, action: PayloadAction<string[]>) {
      if (state.status === 'submitting') return;
      return { status: 'idle', selectedIds: action.payload };
    },

    confirmationRequested(
      state,
      action: PayloadAction<{ operation: 'archive' | 'delete' }>,
    ) {
      if (state.status !== 'idle') return;
      if (state.selectedIds.length === 0) return;
      return {
        status: 'confirming',
        selectedIds: state.selectedIds,
        operation: action.payload.operation,
      };
    },

    submissionStarted(state, action: PayloadAction<{ requestId: string }>) {
      if (state.status !== 'confirming') return;
      return {
        status: 'submitting',
        selectedIds: state.selectedIds,
        operation: state.operation,
        requestId: action.payload.requestId,
      };
    },

    submissionFailed(state, action: PayloadAction<{ message: string }>) {
      if (state.status !== 'submitting') return;
      return {
        status: 'failed',
        selectedIds: state.selectedIds,
        operation: state.operation,
        message: action.payload.message,
      };
    },

    dismissed() {
      return initialState;
    },
  },
});

Perhatikan bentuk state:

status bukan boolean terpisah.
State shape mencegah illegal combinations.
Action names adalah domain vocabulary UI.
Reducer menjadi transition table yang bisa dites.

Redux Toolkit cocok karena workflow ini lebih dari “shared boolean”. Ia adalah state machine-ish client workflow yang butuh traceability.


8. Comparison by Architectural Dimension

8.1 Ownership

ToolOwnership Shape
ContextProvider owns value for subtree
ZustandStore owns state; component selects slice
Redux ToolkitStore owns state; reducers own transition rules

Kesalahan umum:

Menggunakan Context ketika ownership sebenarnya external store.
Menggunakan Zustand ketika ownership sebenarnya server cache.
Menggunakan Redux ketika ownership sebenarnya local component.

8.2 Update Frequency

Update FrequencyBest FitWhy
Very lowContextTheme/locale/capability stable
MediumContext or ZustandDepends on consumer fan-out
HighZustand / ReduxSelector subscription contains blast radius
Per keystroke localLocal stateJangan angkat tanpa alasan
Server refreshQuery cache / RTK QueryServer authority/invalidation

Context dengan high-frequency state sering mahal bukan karena Context “jelek”, tetapi karena Context tidak didesain sebagai fine-grained subscription store.


8.3 Sharing Radius

RadiusFit
Single componentLocal state
Parent + direct childrenLifted state
Subtree dependencyContext
Cross-tree feature stateZustand / Redux
App-wide governed stateRedux Toolkit
Shareable browser stateURL
Server-owned dataQuery cache / RTK Query

Sharing radius yang terlalu luas adalah smell. State yang bisa local jangan dipaksa global.


8.4 Observability

ToolObservability Default
ContextWeak; mostly React DevTools/tree inspection
ZustandMedium; can add devtools/logging middleware
Redux ToolkitStrong; action timeline, middleware, DevTools ecosystem

Jika debugging production membutuhkan pertanyaan:

Action apa yang mengubah state ini?
Urutannya apa?
Payload-nya apa?
Middleware apa yang berjalan?

Redux Toolkit lebih natural.

Jika debugging cukup dengan:

State slice ini berubah kapan dan consumer mana yang rerender?

Zustand bisa cukup.

Jika debugging adalah:

Provider mana yang memberi capability ini?

Context cukup.


8.5 Team Scale

Team scale bukan hanya jumlah engineer. Team scale berarti:

- banyak ownership boundary,
- banyak feature team,
- turnover,
- onboarding,
- consistency requirement,
- refactor koordinasi,
- code review governance.
Team/Codebase ShapeBest Fit
Small feature team, local product surfaceContext/Zustand
Medium app, several independent featuresZustand + Context + Query cache
Large app, many teams, shared business stateRedux Toolkit or state machine layer
Design system primitivesContext + headless/compound patterns
Server-heavy appQuery cache first, store second

Redux sering terlihat berat di awal. Tetapi pada team besar, convention bisa lebih murah daripada kebebasan tanpa batas.

Zustand sering sangat produktif. Tetapi pada team besar, tanpa convention, banyak store kecil bisa berubah menjadi distributed global state yang sulit diaudit.

Context sangat natural. Tetapi jika dipakai sebagai store, coupling tersembunyinya sering baru terasa setelah tree besar.


9. Decision Matrix by State Category

State CategoryRecommended ToolReason
ThemeContextLow-frequency dependency
Locale/i18n readerContextCapability/configuration
Form field label/error linkageContextScoped compound relation
Input draftLocal stateLocal ownership
Multi-field form aggregateLocal reducer/form libraryInvariant and validation graph
Search filters shareable in URLURL stateShareable/navigation semantics
Server list dataTanStack Query / RTK QueryServer authority/cache/invalidation
Selected row IDs in large tableZustand/ReduxShared client state + selector need
Modal managerContext capability or ZustandDepends on stack complexity
Toast serviceContext capabilityCommand/capability, low read need
Workspace layoutZustandClient-owned shared state
Complex domain client workflowRedux Toolkit or XStateTransition trace/invariant
Normalized non-server client graphRedux ToolkitEntity helpers/governance
Permissions snapshotContext or Query cache + Context readerServer source, UI capability reader
Auth session raw tokenNeither for sensitive tokenAvoid unnecessary JS persistence
Feature flagsContext capability/query-backedScoped dependency
Analytics loggerContext capabilityDependency injection
Cross-microfrontend communicationEvent bridge/external store carefullyExplicit boundary

No single tool dominates this table. That is the point.


10. Same Problem, Three Implementations

Misalnya kita punya UI selection untuk table. Masalah:

- user bisa select/unselect row,
- toolbar butuh jumlah selected,
- row butuh tahu apakah dirinya selected,
- bulk action butuh selected IDs.

10.1 Context Version

Cocok jika table kecil/sedang dan subtree jelas.

type SelectionContextValue = {
  selectedIds: ReadonlySet<string>;
  toggle(id: string): void;
  clear(): void;
};

const SelectionContext = createContext<SelectionContextValue | null>(null);

function SelectionProvider({ children }: { children: React.ReactNode }) {
  const [selectedIds, setSelectedIds] = useState(() => new Set<string>());

  const value = useMemo<SelectionContextValue>(() => ({
    selectedIds,
    toggle(id) {
      setSelectedIds((prev) => {
        const next = new Set(prev);
        if (next.has(id)) next.delete(id);
        else next.add(id);
        return next;
      });
    },
    clear() {
      setSelectedIds(new Set());
    },
  }), [selectedIds]);

  return <SelectionContext.Provider value={value}>{children}</SelectionContext.Provider>;
}

Trade-off:

Simple, scoped, easy.
But every consumer of SelectionContext observes same context value change.

10.2 Zustand Version

Cocok jika table besar dan row-level subscription penting.

type SelectionState = {
  selected: Record<string, true>;
  toggle(id: string): void;
  clear(): void;
};

const useSelection = create<SelectionState>((set) => ({
  selected: {},
  toggle: (id) =>
    set((state) => {
      const selected = { ...state.selected };
      if (selected[id]) delete selected[id];
      else selected[id] = true;
      return { selected };
    }),
  clear: () => set({ selected: {} }),
}));

function Row({ id }: { id: string }) {
  const isSelected = useSelection((state) => Boolean(state.selected[id]));
  const toggle = useSelection((state) => state.toggle);
  return <button aria-pressed={isSelected} onClick={() => toggle(id)}>{id}</button>;
}

Trade-off:

Granular and compact.
But global singleton needs discipline or scoped store provider.

10.3 Redux Toolkit Version

Cocok jika selection bagian dari workflow besar, banyak team, action log penting.

const selectionSlice = createSlice({
  name: 'selection',
  initialState: { selected: {} as Record<string, true> },
  reducers: {
    rowToggled(state, action: PayloadAction<string>) {
      const id = action.payload;
      if (state.selected[id]) delete state.selected[id];
      else state.selected[id] = true;
    },
    selectionCleared(state) {
      state.selected = {};
    },
  },
});

const selectIsSelected = (id: string) => (state: RootState) =>
  Boolean(state.selection.selected[id]);

Trade-off:

More structure.
Better action vocabulary, debugging, and shared convention.

The right answer depends on why selection exists.

Small table UI detail? Context/local.
Large interactive workspace? Zustand.
Audit-relevant workflow? Redux Toolkit.

11. The “Server State” Boundary

A common mistake:

Fetch server data, put it into Context/Zustand/Redux manually, then manually track loading/error/stale/invalidation.

This recreates a bad query cache.

Server state has different properties from client state:

- owned by server,
- can become stale without local action,
- has cache lifecycle,
- has refetch/invalidation semantics,
- has loading/error/retry state,
- has optimistic mutation conflict risk,
- can be shared by query key.

Decision:

Use Redux Toolkit with RTK Query if your app is already Redux-governed and server cache should live in that ecosystem. Use TanStack Query if you want framework-agnostic server-state cache with dedicated query/mutation model.

Do not turn Context into cache. Do not turn Zustand into cache unless you are intentionally building a domain-specific cache and accepting the complexity. Do not use Redux slices as manual server cache when RTK Query or TanStack Query solves the lifecycle better.


12. Tool Selection by Failure Mode

Sometimes the best way to choose a tool is to ask what failure you fear most.

Failure You FearPrefer
Prop drilling of stable capabilityContext
Rerender fan-out from shared stateZustand/Redux with selectors
Lack of action traceRedux Toolkit
Too much boilerplate slowing feature teamsZustand/Context
Global singleton sprawlContext/scoped store/Redux governance
Accidental inconsistent domain transitionsRedux Toolkit/state machine
Manual server cache bugsQuery cache/RTK Query
Hard-to-test hidden dependencyExplicit props or Context with provider harness
Large normalized entity graphRedux Toolkit
Overcoupled page orchestrationDomain hooks + local reducer + query cache

Good architecture is not choosing the most powerful tool. Good architecture is choosing the smallest tool that preserves the required invariant.


13. Context vs Zustand vs Redux in Performance Terms

Performance should not be guessed. But architecture can reduce obvious risk.

Context performance profile

- Consumer rerenders when the context value it reads changes.
- Provider value identity matters.
- Splitting context can reduce blast radius.
- Context is fine for low-frequency dependencies.
- Context is not a fine-grained selector subscription system by default.

Use when:

Value changes rarely or consumer count is controlled.

Avoid when:

Hundreds of rows consume high-frequency changing state.

Zustand performance profile

- Component subscribes to selected slice.
- Selector/equality controls rerender.
- Store update can be granular if selectors are stable.
- Bad selectors returning new objects cause rerenders.
- Global store can create hidden dependencies.

Use when:

Many consumers need different small slices.

Avoid when:

Team lacks store conventions and state becomes opaque global mutable architecture.

Redux Toolkit performance profile

- React-Redux uses subscription and selector model.
- Selectors can memoize derived data.
- Normalized state helps update locality.
- Action dispatch is explicit and observable.
- Boilerplate/convention overhead exists.

Use when:

State transition trace and team convention matter as much as render performance.

Avoid when:

The state is component-local or purely server-owned.

14. State Governance Spectrum

Increasing governance usually means:

+ stronger conventions
+ better traceability
+ clearer transition vocabulary
+ easier team-wide policy
- more ceremony
- more upfront design
- more abstraction surface

The mistake is not choosing Redux. The mistake is using Redux for state that does not need governance.

The mistake is not choosing Zustand. The mistake is using Zustand as ungoverned global mutable memory.

The mistake is not choosing Context. The mistake is using Context as an unstructured global state container.


15. Migration Playbooks

15.1 Context to Zustand

Use when:

- context value changes frequently,
- many consumers read small parts,
- splitting providers becomes awkward,
- rerender fan-out is visible in profiler.

Steps:

1. Identify context value shape.
2. Separate read state from commands.
3. Move state and commands to external store.
4. Replace context consumer with selector hook.
5. Keep provider only if store must be scoped per subtree.
6. Add tests for state transitions.
7. Profile before/after.

Before:

const { selectedIds, toggle } = useSelectionContext();
const selected = selectedIds.includes(id);

After:

const selected = useSelectionStore((s) => Boolean(s.selected[id]));
const toggle = useSelectionStore((s) => s.toggle);

Invariant:

No component should subscribe to more state than it renders.

15.2 Zustand to Redux Toolkit

Use when:

- action vocabulary becomes important,
- many teams change the same store,
- debugging needs timeline,
- side effects need consistent middleware,
- normalized entity state grows,
- store conventions are drifting.

Steps:

1. Extract event names from Zustand actions.
2. Define slice state and reducers.
3. Move derived reads into selectors.
4. Move async orchestration to thunk/listener/RTK Query boundary.
5. Add action-level tests.
6. Deprecate old store action by action.

Do not migrate because Redux is “more enterprise”. Migrate because governance cost is lower than ad-hoc store sprawl.


15.3 Redux Toolkit to Zustand or Local State

Use when:

- slice is only used by one feature,
- action log provides no value,
- state is UI-only and small,
- reducer ceremony hides simple behavior,
- team wants feature-local ownership.

Steps:

1. Audit slice consumers.
2. If only one route/page owns it, move state to page reducer or Zustand scoped store.
3. Replace global selectors with feature hook.
4. Keep action names as command names.
5. Delete reducer/middleware only after tests pass.

The goal is not minimalism. The goal is correct ownership.


15.4 Store to Query Cache

Use when:

- state is server-owned,
- local update is mostly cache update,
- code manually tracks loading/error/refetch,
- invalidation bugs appear,
- multiple pages fetch same entity.

Steps:

1. Identify server authority.
2. Define query keys.
3. Replace manual fetch effect/store state with query hook.
4. Move mutation lifecycle to mutation API.
5. Replace manual entity sync with invalidation or optimistic cache update.
6. Keep only client-owned UI state in store.

Server state is not a moral category. It is an authority category.


16. Architecture Recipes

16.1 Admin Dashboard

Recommended composition:

Context:
  - theme
  - permission reader
  - analytics

Query cache:
  - users
  - reports
  - metrics

Zustand:
  - dashboard layout
  - open panels
  - local workspace filters not shareable in URL

URL:
  - active tab
  - committed filters
  - pagination

Redux Toolkit:
  - only if dashboard workflows need shared action governance

Do not put metrics response into Context. Do not put keystroke filter draft into Redux unless it drives governed workflow.


16.2 Case Management Workbench

Recommended composition:

Context:
  - case permission reader
  - audit logger capability
  - command bus capability for page-level actions

Query cache:
  - case details
  - documents
  - related parties
  - timeline

Zustand:
  - local workbench panel layout
  - selected document IDs
  - open drawers

Redux Toolkit:
  - cross-feature enforcement workflow state
  - bulk action state
  - normalized client-only review graph

State machine:
  - escalation lifecycle
  - multi-step approval with legal states

Why multiple tools?

Because multiple state categories exist. A top-tier React codebase rarely uses one tool for all state. It uses clear ownership boundaries.


16.3 Design System

Recommended composition:

Context:
  - theme tokens
  - density
  - direction
  - form field linkage
  - compound component coordination

Local state:
  - open state for primitive when uncontrolled
  - focus index
  - transient interaction state

External store:
  - usually avoid inside primitive design-system components

Design system components should not silently depend on app-global Zustand/Redux stores. They should be portable.


16.4 Complex Editor

Recommended composition:

Local reducer:
  - small editor sections

Zustand/scoped external store:
  - large editor document state
  - selection/cursor/sidebar state
  - history/undo stack if client-owned

Query cache:
  - server document loading/saving

Redux Toolkit:
  - if editor state shared across many product modules with action governance

State machine:
  - save lifecycle, conflict resolution, publish workflow

Editor state is often client-owned and high-frequency. Context alone usually fails here.


17. ADR Template for Choosing State Tool

Use this template in code review for important state architecture decisions.

# ADR: State ownership for <feature>

## Context
What state is being introduced?
Who reads it?
Who writes it?
How often does it change?

## State Category
- [ ] local
- [ ] derived
- [ ] lifted
- [ ] context capability
- [ ] shared client state
- [ ] server state
- [ ] URL state
- [ ] persistent client state
- [ ] workflow state

## Decision
We will use <Context/Zustand/Redux Toolkit/Query Cache/Local>.

## Why
Explain ownership, lifecycle, sharing radius, update frequency, and governance need.

## Alternatives Rejected
Why not local?
Why not Context?
Why not Zustand?
Why not Redux Toolkit?
Why not query cache?

## Invariants
What must always be true?

## Failure Modes
What can go wrong and how will we detect it?

## Migration Plan
How can this decision be reversed if scale changes?

This prevents “library preference” from masquerading as architecture.


18. Code Review Heuristics

Ask these questions during review:

Is this state server-owned?
If yes, why is it not in a query cache?

Is this state read by many unrelated branches?
If yes, why is the sharing radius so wide?

Is this Context value changing frequently?
If yes, how is rerender fan-out controlled?

Is this Zustand store a singleton?
If yes, should it be scoped per page/session/test?

Is this Redux slice local to one component?
If yes, why is it global?

Are raw setters exposed across boundaries?
If yes, what invariant prevents illegal transitions?

Are actions named after UI events or domain intents?
Is that intentional?

Is persistence versioned and cleared on logout/tenant switch?

Are selectors stable?
Do they allocate new objects every call?

Can this state be reset by key/route unmount instead of global mutation?

A good review does not ask “why not Redux?” or “why not Zustand?” in isolation. It asks whether the chosen tool preserves the state invariant with the least long-term cost.


19. Anti-Pattern Map

19.1 Context as App Dump

Symptom:

One AppContext contains user, permissions, theme, filters, drafts, notifications, and setters.

Fix:

Split by capability/domain/lifecycle.
Move volatile shared state to external store.
Move server state to query cache.
Move local drafts down.

19.2 Zustand Everywhere

Symptom:

Every feature creates a global singleton store.
State ownership becomes invisible.

Fix:

Require store owner per feature.
Prefer scoped store for route/workspace state.
Document persistence/version policy.
Add selectors and action naming convention.

19.3 Redux for Keystrokes

Symptom:

Every input change dispatches global actions despite no shared/governed need.

Fix:

Keep draft local.
Commit meaningful intent upward.
Use Redux only for workflow/domain transition, not every transient edit.

19.4 Manual Server Cache in Store

Symptom:

Store tracks data, loading, error, lastFetchedAt, retry, stale flags, invalidation maps.

Fix:

Use TanStack Query or RTK Query.
Keep client-only coordination state in store.

19.5 Tool Monoculture

Symptom:

Team rule says everything must use Redux, or everything must use Zustand, or Context only.

Fix:

Classify state first.
Use multiple tools with explicit boundaries.
Create decision matrix.

A single state tool for all state categories is usually architecture laziness disguised as consistency.


20. Final Decision Framework

Use this sequence:

1. Can it be derived during render?
   -> derive, do not store.

2. Is it owned by one component?
   -> local state.

3. Do several children need to coordinate?
   -> lift to closest common owner.

4. Is it shareable browser/navigation state?
   -> URL state.

5. Is it server-owned?
   -> query cache / RTK Query.

6. Is it a stable subtree dependency/capability?
   -> Context.

7. Is it shared client state needing fine-grained subscription and low ceremony?
   -> Zustand.

8. Is it shared client state needing governance, action trace, middleware, and team convention?
   -> Redux Toolkit.

9. Is it a legal-state workflow with guards/actions/invoked async behavior?
   -> reducer/state machine.

Mermaid version:


21. Practical Defaults

For most advanced React applications, a healthy default stack looks like this:

Local state:
  component-specific transient state

useReducer:
  local complex transition/invariant

Context:
  capability/configuration/subtree dependency

URL:
  shareable navigation/search state

Query cache:
  server-owned data

Zustand:
  shared client UI/workspace state needing selectors

Redux Toolkit:
  governed shared client state with action/middleware/team-scale needs

State machine:
  explicit workflow lifecycle with legal/illegal states

This is not tool sprawl if boundaries are clear. It is state taxonomy implemented honestly.


22. Exercises

Exercise 1 — Classify State

Take one existing screen and classify every state variable:

local / derived / lifted / context / URL / server / external store / workflow / persistent

Then answer:

Which state is currently too global?
Which state is currently too local?
Which state is duplicated?
Which state has unclear authority?

Exercise 2 — Replace AppContext

Given an AppContext, split it into:

- capability context,
- query cache data,
- local page state,
- Zustand feature store,
- Redux governed slice if truly needed.

Write an ADR explaining each move.


Exercise 3 — Same Feature, Three Tools

Implement the same selection feature with:

- Context,
- Zustand,
- Redux Toolkit.

Then compare:

bundle/API surface,
render fan-out,
testability,
action observability,
migration cost.

The goal is not to memorize syntax. The goal is to feel the architectural trade-off.


23. Key Takeaways

Context is not a global store replacement.
Zustand is not a license for ungoverned global state.
Redux Toolkit is not mandatory for every shared state.
Server state does not belong in random client stores.
The correct tool follows from authority, ownership, lifecycle, sharing radius, volatility, and governance.

The top 1% React engineer is not the one who always picks the same state library. It is the one who can explain why a specific state belongs exactly where it lives, what invariant that placement protects, and what failure mode it avoids.

Lesson Recap

You just completed lesson 65 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.