Build CoreOrdered learning track

Redux Toolkit in Production

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

Redux Toolkit untuk React production: configureStore, slices, typed hooks, selectors, entity adapter, listener middleware, async lifecycle, RTK Query boundary, testing, dan governance.

6 min read1174 words
PrevNext
Lesson 64123 lesson track24–67 Build Core
#react#redux#redux-toolkit#state-management+3 more

Part 064 — Redux Toolkit in Production

Redux sering disalahpahami dari dua arah.

Arah pertama:

Redux itu selalu terlalu berat.

Arah kedua:

Semua state besar harus masuk Redux.

Keduanya lemah. Redux Toolkit bukan sekadar global state library. Dalam production team, Redux Toolkit adalah governed state architecture: action log, reducer transition, typed selectors, middleware, normalized entity utilities, async lifecycle convention, DevTools, dan ecosystem yang matang.

Gunakan Redux Toolkit saat masalahnya bukan hanya “component butuh state bersama”, tetapi:

- banyak team menyentuh state yang sama,
- transisi harus bisa ditelusuri,
- debugging butuh action timeline,
- state shape butuh normalisasi,
- business workflow butuh event/action vocabulary,
- side effects perlu middleware boundary,
- large app butuh conventions yang seragam,
- auditability dan regression testing lebih penting daripada minimal API.

Redux Toolkit adalah trade-off:

Lebih banyak struktur di depan.
Lebih sedikit kekacauan saat aplikasi dan tim membesar.

1. Redux Toolkit Dalam Taxonomy State

Redux Toolkit cocok untuk shared client state yang butuh governance. Bukan semua state.

StateRedux Toolkit?Catatan
Local input draftTidakPakai useState / form library
Modal open boolean lokalTidakLocal/lifted/context cukup
App-wide UI shellBisaJika banyak feature bergantung
Complex client entity stateYacreateEntityAdapter membantu
Server data cacheBiasanya RTK Query, bukan slice manualRTK Query bagian dari RTK ecosystem
Long-running workflowBisaReducer/action log berguna, tapi state machine mungkin lebih tepat
Permission/capability snapshotBisaTetap bukan final server authority
Audit-relevant UI transitionsYaAction timeline membantu debugging
Multi-step wizard shared across routeBisaJika state perlu predictable transitions

Decision diagram:


2. What Redux Toolkit Gives You

Redux Toolkit standardizes modern Redux usage.

Core pieces:

configureStore
  - creates store with good defaults
  - includes thunk middleware by default
  - sets up DevTools integration
  - enables middleware customization

createSlice
  - defines reducer + action creators together
  - uses Immer-style reducer logic internally
  - reduces action type boilerplate

createAsyncThunk
  - generates pending/fulfilled/rejected action lifecycle
  - includes requestId and thunkAPI

createEntityAdapter
  - normalized entity helpers
  - generated selectors

createSelector
  - memoized selectors

listenerMiddleware
  - action-driven side effects / workflows

RTK Query
  - data fetching/cache abstraction for Redux apps

Production meaning:

You get a language for state transitions.
You get a timeline of actions.
You get repeatable conventions for teams.

3. Basic Store Configuration

A production RTK app starts with store setup that exports types.

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { caseWorkspaceReducer } from '@/features/case-workspace/case-workspace.slice';
import { invoiceReviewReducer } from '@/features/invoice-review/invoice-review.slice';

export const store = configureStore({
  reducer: {
    caseWorkspace: caseWorkspaceReducer,
    invoiceReview: invoiceReviewReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Typed hooks:

// app/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

Provider:

import { Provider } from 'react-redux';
import { store } from './store';

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

Rule:

Never import RootState from feature slice.
RootState belongs to app/store boundary.
Feature selectors may accept RootState via exported selector functions.

4. Slice as State Boundary

A slice should represent a coherent state boundary. Not necessarily one backend resource.

Bad slice names:

miscSlice
appSlice
commonSlice
dataSlice

Better:

caseWorkspaceSlice
invoiceReviewSlice
notificationCenterSlice
approvalDraftSlice

Example:

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

type CaseWorkspaceState = {
  activeTab: 'summary' | 'evidence' | 'timeline' | 'decision';
  selectedEvidenceIds: string[];
  rightPanel:
    | { tag: 'closed' }
    | { tag: 'evidenceDetail'; evidenceId: string }
    | { tag: 'commentThread'; threadId: string };
};

const initialState: CaseWorkspaceState = {
  activeTab: 'summary',
  selectedEvidenceIds: [],
  rightPanel: { tag: 'closed' },
};

const caseWorkspaceSlice = createSlice({
  name: 'caseWorkspace',
  initialState,
  reducers: {
    tabOpened(state, action: PayloadAction<CaseWorkspaceState['activeTab']>) {
      state.activeTab = action.payload;
    },

    evidenceToggled(state, action: PayloadAction<{ evidenceId: string }>) {
      const id = action.payload.evidenceId;
      const exists = state.selectedEvidenceIds.includes(id);

      state.selectedEvidenceIds = exists
        ? state.selectedEvidenceIds.filter((x) => x !== id)
        : [...state.selectedEvidenceIds, id];
    },

    evidenceDetailOpened(state, action: PayloadAction<{ evidenceId: string }>) {
      state.rightPanel = {
        tag: 'evidenceDetail',
        evidenceId: action.payload.evidenceId,
      };
    },

    rightPanelClosed(state) {
      state.rightPanel = { tag: 'closed' };
    },

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

export const caseWorkspaceReducer = caseWorkspaceSlice.reducer;
export const caseWorkspaceActions = caseWorkspaceSlice.actions;

Notice action names:

tabOpened
evidenceToggled
evidenceDetailOpened
rightPanelClosed
workspaceReset

These are domain/UI events, not setters.

Anti-pattern:

setActiveTab(state, action)
setSelectedEvidenceIds(state, action)
setRightPanel(state, action)

Setters leak implementation. Events describe intent.


5. Immer Is Not A License For Bad State

Redux Toolkit lets you write mutating-looking reducer code using Immer.

state.activeTab = 'timeline';

This is fine because Immer produces immutable updates. But it does not fix bad state design.

Bad state:

type State = {
  loading: boolean;
  success: boolean;
  error: boolean;
  data?: Case;
  errorMessage?: string;
};

This allows impossible state:

loading=true and success=true and error=true

Better:

type LoadState =
  | { tag: 'idle' }
  | { tag: 'loading'; requestId: string }
  | { tag: 'success'; caseId: string }
  | { tag: 'error'; message: string };

Reducer should model transitions, not mutate booleans independently.


6. Selectors as Read Model

Do not let components know state shape too deeply. Export selectors.

// case-workspace.selectors.ts
import type { RootState } from '@/app/store';

export const selectCaseWorkspace = (state: RootState) => state.caseWorkspace;

export const selectActiveTab = (state: RootState) =>
  selectCaseWorkspace(state).activeTab;

export const selectSelectedEvidenceIds = (state: RootState) =>
  selectCaseWorkspace(state).selectedEvidenceIds;

export const selectSelectedEvidenceCount = (state: RootState) =>
  selectSelectedEvidenceIds(state).length;

export const selectIsEvidenceSelected = (evidenceId: string) =>
  (state: RootState) => selectSelectedEvidenceIds(state).includes(evidenceId);

Component:

function EvidenceBadge() {
  const count = useAppSelector(selectSelectedEvidenceCount);
  return <span>{count}</span>;
}

Selector rule:

Component asks questions.
Selectors know state shape.
Reducers own transitions.

7. Memoized Selectors

When derived data is expensive or returns new references, use memoized selectors.

import { createSelector } from '@reduxjs/toolkit';

const selectEvidenceEntities = (state: RootState) =>
  state.evidence.entities;

const selectSelectedEvidenceIds = (state: RootState) =>
  state.caseWorkspace.selectedEvidenceIds;

export const selectSelectedEvidenceItems = createSelector(
  [selectEvidenceEntities, selectSelectedEvidenceIds],
  (entities, ids) => ids.map((id) => entities[id]).filter(Boolean)
);

Avoid inline object selectors without equality:

const value = useAppSelector((state) => ({
  count: state.caseWorkspace.selectedEvidenceIds.length,
  activeTab: state.caseWorkspace.activeTab,
}));

This creates a new object each call. Use separate selectors, memoized selector, or shallowEqual where appropriate.

import { shallowEqual } from 'react-redux';

const value = useAppSelector(
  (state) => ({
    count: selectSelectedEvidenceCount(state),
    activeTab: selectActiveTab(state),
  }),
  shallowEqual
);

8. Normalized State With createEntityAdapter

Redux shines when state is normalized and many parts of UI read the same entities.

Without normalization:

type State = {
  cases: Array<{
    id: string;
    title: string;
    evidence: Evidence[];
  }>;
};

Problems:

- same entity duplicated in multiple arrays,
- updates require deep traversal,
- row component rerenders too widely,
- optimistic updates are harder,
- selectors become fragile.

With entity adapter:

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

type Evidence = {
  id: string;
  caseId: string;
  label: string;
  status: 'new' | 'reviewed' | 'excluded';
};

const evidenceAdapter = createEntityAdapter<Evidence>({
  sortComparer: (a, b) => a.label.localeCompare(b.label),
});

const evidenceSlice = createSlice({
  name: 'evidence',
  initialState: evidenceAdapter.getInitialState({
    loadedCaseIds: [] as string[],
  }),
  reducers: {
    evidenceLoaded: evidenceAdapter.upsertMany,
    evidenceStatusChanged(
      state,
      action: PayloadAction<{ id: string; status: Evidence['status'] }>
    ) {
      evidenceAdapter.updateOne(state, {
        id: action.payload.id,
        changes: { status: action.payload.status },
      });
    },
  },
});

Selectors:

const evidenceSelectors = evidenceAdapter.getSelectors<RootState>(
  (state) => state.evidence
);

export const selectEvidenceById = evidenceSelectors.selectById;
export const selectAllEvidence = evidenceSelectors.selectAll;

Feature-specific selector:

export const selectEvidenceForCase = (caseId: string) =>
  createSelector([selectAllEvidence], (items) =>
    items.filter((item) => item.caseId === caseId)
  );

Caveat:

Selector factory must be scoped carefully.
If component creates a new memoized selector every render, memo cache is lost.
Use useMemo for parameterized memoized selector when needed.

9. Async Logic: Three Options

Redux Toolkit has several async boundaries.

1. createAsyncThunk
   - simple request lifecycle
   - manual reducers handle pending/fulfilled/rejected

2. listenerMiddleware
   - action-driven side effects
   - workflows triggered by actions
   - cancellation/debounce/orchestration

3. RTK Query
   - server-state cache
   - query/mutation endpoints
   - invalidation tags
   - generated hooks

Do not use slice + thunk as a homemade query cache if RTK Query fits.


10. createAsyncThunk: Request Lifecycle, Not Cache Strategy

Example:

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

export const fetchCaseSummary = createAsyncThunk(
  'caseSummary/fetchCaseSummary',
  async (caseId: string, thunkApi) => {
    const response = await caseApi.getSummary(caseId, {
      signal: thunkApi.signal,
    });
    return response.data;
  }
);

type CaseSummaryState = {
  entity: CaseSummary | null;
  status:
    | { tag: 'idle' }
    | { tag: 'loading'; requestId: string }
    | { tag: 'success' }
    | { tag: 'error'; message: string };
};

const initialState: CaseSummaryState = {
  entity: null,
  status: { tag: 'idle' },
};

const caseSummarySlice = createSlice({
  name: 'caseSummary',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchCaseSummary.pending, (state, action) => {
        state.status = {
          tag: 'loading',
          requestId: action.meta.requestId,
        };
      })
      .addCase(fetchCaseSummary.fulfilled, (state, action) => {
        if (
          state.status.tag === 'loading' &&
          state.status.requestId !== action.meta.requestId
        ) {
          return;
        }

        state.entity = action.payload;
        state.status = { tag: 'success' };
      })
      .addCase(fetchCaseSummary.rejected, (state, action) => {
        if (
          state.status.tag === 'loading' &&
          state.status.requestId !== action.meta.requestId
        ) {
          return;
        }

        state.status = {
          tag: 'error',
          message: action.error.message ?? 'Failed to fetch case summary',
        };
      });
  },
});

createAsyncThunk gives request lifecycle actions. It does not decide your loading state shape or cache invalidation strategy. You still own that design.


11. Listener Middleware: Action-Driven Effects

Listener middleware is useful when an action should trigger side effects without putting effect logic in React components.

Example:

import { createListenerMiddleware } from '@reduxjs/toolkit';
import { caseWorkspaceActions } from '@/features/case-workspace/case-workspace.slice';

export const listenerMiddleware = createListenerMiddleware();

listenerMiddleware.startListening({
  actionCreator: caseWorkspaceActions.evidenceDetailOpened,
  effect: async (action, listenerApi) => {
    analytics.track('evidence_detail_opened', {
      evidenceId: action.payload.evidenceId,
    });
  },
});

Store setup:

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().prepend(listenerMiddleware.middleware),
});

Good uses:

- analytics after domain/UI action,
- debounce search action,
- cancel stale workflows,
- persist small preference after action,
- coordinate actions across slices,
- trigger toast on fulfilled/rejected action.

Bad uses:

- hiding core business transition outside reducer,
- making implicit action chains nobody can trace,
- replacing server-state cache,
- mixing UI event and backend transaction without idempotency.

12. RTK Query Boundary

RTK Query is intended for server-state fetching/caching in Redux apps. Use it when you need query keys, caching, invalidation, mutations, and generated hooks.

Example sketch:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const caseApi = createApi({
  reducerPath: 'caseApi',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['Case', 'Evidence'],
  endpoints: (builder) => ({
    getCase: builder.query<CaseDto, string>({
      query: (caseId) => `/cases/${caseId}`,
      providesTags: (_result, _error, caseId) => [{ type: 'Case', id: caseId }],
    }),

    approveCase: builder.mutation<void, { caseId: string }>({
      query: ({ caseId }) => ({
        url: `/cases/${caseId}/approve`,
        method: 'POST',
      }),
      invalidatesTags: (_result, _error, { caseId }) => [
        { type: 'Case', id: caseId },
      ],
    }),
  }),
});

export const { useGetCaseQuery, useApproveCaseMutation } = caseApi;

Store setup:

export const store = configureStore({
  reducer: {
    [caseApi.reducerPath]: caseApi.reducer,
    caseWorkspace: caseWorkspaceReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(caseApi.middleware),
});

Boundary rule:

RTK Query owns remote data cache.
Slices own client UI/workflow state.
Do not duplicate server entities into slices unless you have a clear denormalized client editing model.

13. Component Usage Pattern

A component should read via selectors and dispatch action intent.

function EvidenceRow({ evidenceId }: { evidenceId: string }) {
  const dispatch = useAppDispatch();
  const evidence = useAppSelector((state) =>
    selectEvidenceById(state, evidenceId)
  );
  const selected = useAppSelector(selectIsEvidenceSelected(evidenceId));

  if (!evidence) return null;

  return (
    <button
      aria-pressed={selected}
      onClick={() =>
        dispatch(caseWorkspaceActions.evidenceToggled({ evidenceId }))
      }
    >
      {evidence.label}
    </button>
  );
}

Keep component free from state shape details:

// Bad
const selected = state.caseWorkspace.selectedEvidenceIds.includes(evidenceId);

// Good
const selected = useAppSelector(selectIsEvidenceSelected(evidenceId));

14. Feature Folder Structure

Recommended:

src/
  app/
    store.ts
    hooks.ts
  features/
    case-workspace/
      case-workspace.slice.ts
      case-workspace.selectors.ts
      case-workspace.types.ts
      case-workspace.listener.ts
      components/
        CaseWorkspacePage.tsx
        EvidencePanel.tsx
    evidence/
      evidence.slice.ts
      evidence.selectors.ts
    case-api/
      case-api.ts

Avoid folder-by-type for large apps:

reducers/
actions/
selectors/
components/

Feature folders preserve locality. Shared app store composes reducers.


15. Cross-Slice Coordination

Cross-slice coordination should be explicit.

Option 1 — one action handled by multiple slices:

const caseClosed = createAction<{ caseId: string }>('case/caseClosed');

const workspaceSlice = createSlice({
  name: 'caseWorkspace',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(caseClosed, () => initialState);
  },
});

const notificationsSlice = createSlice({
  name: 'notifications',
  initialState: notificationsInitialState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(caseClosed, (state, action) => {
      state.items.push({
        id: crypto.randomUUID(),
        message: `Case ${action.payload.caseId} was closed`,
      });
    });
  },
});

Option 2 — listener dispatches follow-up actions:

listenerMiddleware.startListening({
  actionCreator: caseActions.caseClosed,
  effect: async (action, api) => {
    api.dispatch(caseWorkspaceActions.workspaceReset());
    api.dispatch(notificationActions.toastAdded({
      message: `Case ${action.payload.caseId} closed`,
    }));
  },
});

Rule:

If it is a domain event, multiple reducers may react.
If it is a side effect, listener middleware may react.
Do not make component manually dispatch five low-level actions unless that sequence is truly UI-specific.

16. Middleware Governance

Redux middleware can become invisible complexity. Use it deliberately.

Default middleware handles useful development checks and thunk. Customize only with reason.

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['some/nonSerializableAction'],
      },
    }).prepend(listenerMiddleware.middleware),
});

Serializability rule:

Redux state and actions should generally be serializable.
Do not put class instances, DOM nodes, AbortController, Promise, Map/Set, or functions in Redux state unless you fully understand the debugging/devtools trade-off.

If you need non-serializable handles:

- store them in refs,
- external service registry,
- listener middleware local scope,
- or a dedicated non-React module.

17. Persistence With Redux

Persist only deliberate state.

Examples that may be persisted:

- user UI preferences,
- dismissed banners,
- saved table columns,
- draft if product requirement demands it.

Avoid persisting:

- loading flags,
- server cache without invalidation model,
- sensitive auth tokens without threat model,
- non-serializable values,
- data that should reset on logout/tenant switch.

Persistence policy should answer:

Which slices persist?
Which fields?
What version?
What migration?
What logout cleanup?
What tenant/user key?
What storage quota behavior?

18. Permission-Aware Redux State

For regulatory/enterprise systems, Redux often stores UI capability snapshots.

Example:

type Capability =
  | 'case:read'
  | 'case:comment'
  | 'case:approve'
  | 'case:escalate';

type CapabilityState = {
  capabilitiesByCaseId: Record<string, Capability[]>;
};

export const selectCanApproveCase = (caseId: string) =>
  (state: RootState) =>
    state.capabilities.capabilitiesByCaseId[caseId]?.includes('case:approve') ?? false;

Important:

Client permission state controls UX affordances.
Server still enforces authorization.

Do not use Redux permission state as security boundary. Use it for display, disabled state, routing hints, and explanatory UI.


19. Testing Redux Toolkit

Reducer tests:

test('evidenceToggled selects then deselects evidence', () => {
  let state = caseWorkspaceReducer(
    undefined,
    caseWorkspaceActions.evidenceToggled({ evidenceId: 'e-1' })
  );

  expect(state.selectedEvidenceIds).toEqual(['e-1']);

  state = caseWorkspaceReducer(
    state,
    caseWorkspaceActions.evidenceToggled({ evidenceId: 'e-1' })
  );

  expect(state.selectedEvidenceIds).toEqual([]);
});

Selector tests:

test('selectSelectedEvidenceCount returns count', () => {
  const state = buildRootState({
    caseWorkspace: {
      ...initialState,
      selectedEvidenceIds: ['a', 'b'],
    },
  });

  expect(selectSelectedEvidenceCount(state)).toBe(2);
});

Integration test with store:

function renderWithStore(ui: React.ReactElement, preloadedState?: Partial<RootState>) {
  const store = configureStore({
    reducer: rootReducer,
    preloadedState,
  });

  return {
    store,
    ...render(<Provider store={store}>{ui}</Provider>),
  };
}

Async test:

test('fetchCaseSummary handles fulfilled result', async () => {
  const store = setupTestStore();

  await store.dispatch(fetchCaseSummary('case-1'));

  expect(selectCaseSummaryStatus(store.getState()).tag).toBe('success');
});

Testing rule:

Reducer tests validate transition logic.
Selector tests validate read model.
Integration tests validate wiring.
Avoid testing Redux Toolkit internals.

20. Observability

Redux DevTools is one of Redux's major production-development strengths. A good action log reads like a story.

Good:

caseWorkspace/evidenceToggled
caseWorkspace/evidenceDetailOpened
caseSummary/fetchCaseSummary/pending
caseSummary/fetchCaseSummary/fulfilled
case/approveStarted
case/approveSucceeded

Bad:

setValue
update
changeData
common/setState

For regulated workflow UI, action names become debugging evidence:

At 10:31:22 user opened evidence detail.
At 10:31:25 user selected evidence e-42.
At 10:31:31 approve mutation started.
At 10:31:33 approve mutation rejected.
Toast displayed with reason.

This is not legal audit by itself, but it is valuable engineering observability.


21. Performance Model

Redux performance depends heavily on selectors and state shape.

Avoid:

const root = useAppSelector((state) => state);

Prefer narrow selectors:

const activeTab = useAppSelector(selectActiveTab);

Avoid reducers that replace huge state unnecessarily.

// Bad if it recreates too much
return expensiveDeepCloneAndModify(state);

Prefer normalized updates:

evidenceAdapter.updateOne(state, {
  id,
  changes: { status: 'reviewed' },
});

Rerender containment strategy:

- normalize entities,
- row components select entity by ID,
- memoize expensive derived selectors,
- avoid inline object selectors,
- avoid root-state selection,
- split feature slices by lifecycle and ownership,
- profile before optimizing blindly.

22. Redux Toolkit vs Zustand

DimensionZustandRedux Toolkit
BoilerplateLowerMedium
FreedomHighLower but safer
Team governanceYou must createBuilt-in conventions
Action logMiddleware/devtools possibleCore strength
Reducer disciplineOptionalCentral
Normalized helpersManualcreateEntityAdapter
Async lifecycleManualcreateAsyncThunk, listener, RTK Query
Server-state optionExternal tool usuallyRTK Query integrated
Best fitPragmatic app/client UI storeLarge app shared state with traceability

Rule:

Zustand optimizes local freedom.
Redux Toolkit optimizes shared governance.

23. Redux Toolkit Failure Modes

23.1 Everything Goes Into Redux

Symptom:

input text, hover state, modal local state, fetch cache, permission, form draft, ephemeral animation state all live in Redux.

Fix:

Move local state local.
Move server state to RTK Query/query cache.
Keep Redux for shared client state needing governance.

23.2 Slice Is Just Setters

Symptom:

reducers: {
  setActiveTab,
  setSelectedIds,
  setPanel,
  setLoading,
}

Fix:

Name actions as events/intents.
Encode invariant in reducer.

23.3 Boolean Lifecycle Explosion

Symptom:

loading: false,
loaded: true,
failed: true,

Fix:

Use discriminated union lifecycle state.

23.4 Duplicated Server State

Symptom:

RTK Query cache has case data, slice also has case data, component reads both.

Fix:

One authority.
Use selectors/view models to combine cache data and client UI state.

23.5 Non-Serializable State

Symptom:

Redux DevTools breaks or state cannot be replayed because functions/classes/DOM nodes live in state.

Fix:

Keep Redux state serializable.
Move handles to refs/external services.

23.6 Action Chain Is Hidden In Middleware

Symptom:

Dispatch A mysteriously causes B, C, D across listeners; nobody knows why.

Fix:

Document listener flows.
Keep core state transitions in reducers.
Use listener for side effects/orchestration, not invisible business rules.

24. Refactor Recipes

Local Reducer → Redux Slice

Use when same reducer state must be shared across routes/features.

1. Extract state and events types.
2. Move reducer transitions into createSlice reducers.
3. Replace local dispatch with Redux dispatch.
4. Export selectors.
5. Write reducer tests before moving UI.

Redux Slice → RTK Query

Use when slice is really server cache.

1. Identify fetch lifecycle fields.
2. Define API endpoint and tags.
3. Replace thunk dispatch with generated query hook.
4. Remove duplicated entity state.
5. Keep client UI selection/filter state in slice if needed.

Redux Global → Local State

Use when state has one owner and no shared governance need.

1. Find selectors used by only one subtree.
2. Move state to local reducer.
3. Pass data/commands through props/composition.
4. Remove slice after tests pass.

25. Production Checklist

Before adding or changing Redux state:

[ ] This state has a clear owner slice.
[ ] This state is not better represented as local state.
[ ] This state is not server cache better handled by RTK Query/query layer.
[ ] Action names describe events/intents, not setter mechanics.
[ ] Reducers enforce invariants.
[ ] Lifecycle state avoids boolean explosion.
[ ] Selectors hide internal state shape from components.
[ ] Expensive derived selectors are memoized.
[ ] Entity collections are normalized when needed.
[ ] Async request race/cancellation has been considered.
[ ] Non-serializable values are not stored in Redux state.
[ ] Cross-slice coordination is explicit.
[ ] Tests cover reducers, selectors, and critical integration paths.
[ ] DevTools action log is meaningful.

26. Latihan Implementasi

Bangun Redux Toolkit architecture untuk case approval workspace.

Requirement:

Slices:
- caseWorkspace: activeTab, selectedEvidenceIds, rightPanel
- capabilities: capabilitiesByCaseId
- notificationCenter: toast queue

RTK Query:
- getCase(caseId)
- getEvidence(caseId)
- approveCase(caseId)

Selectors:
- selectActiveTab
- selectSelectedEvidenceCount
- selectCanApproveCase(caseId)
- selectCaseApprovalViewModel(caseId)

Listener middleware:
- when approveCase fulfilled, add success toast and reset selection
- when approveCase rejected, add error toast

Tests:
- reducer transition for selection
- selector for canApprove
- integration for approve fulfilled listener

Constraint:

- Do not duplicate RTK Query case data into a normal slice.
- Do not store DOM nodes or AbortController in Redux state.
- Use discriminated union for rightPanel.
- Use event-style action names.

27. Ringkasan

Redux Toolkit adalah pilihan kuat ketika state bukan hanya soal “sharing”. Ia berguna ketika state perlu governance, traceability, shared conventions, normalized updates, and action-based debugging.

Gunakan Redux Toolkit untuk:

- shared client state dengan banyak pembaca/penulis,
- state transition yang perlu dilacak,
- normalized entity state,
- action-driven side effects,
- app besar dengan banyak engineer,
- UI workflow yang butuh convention kuat.

Jangan gunakan Redux Toolkit untuk:

- semua local input state,
- server cache manual tanpa alasan,
- ephemeral animation/hover state,
- state yang hanya dibaca satu component,
- menyimpan non-serializable handles.

Redux yang baik terasa seperti event log yang bisa dipahami. Redux yang buruk terasa seperti database global untuk semua variabel UI.


Referensi

Lesson Recap

You just completed lesson 64 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.