Build CoreOrdered learning track

Reducer Architecture

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

Reducer architecture untuk React production-grade: event modeling, transition table, reducer purity, invariant, async boundary, selectors, testing, dan migration path menuju store atau state machine.

6 min read1172 words
PrevNext
Lesson 58123 lesson track24–67 Build Core
#react#hooks#usereducer#reducer+2 more

Part 058 — Reducer Architecture

useReducer sering dipahami sebagai alternatif useState ketika state “mulai banyak”.

Itu benar, tapi terlalu dangkal.

Reducer yang baik bukan cuma tempat menaruh update logic. Reducer yang baik adalah transition system kecil:

current state + event → next state

Ia membuat perubahan state bisa dibaca, dites, dan dijaga invariant-nya.


1. Reducer Is Not a Setter With Extra Steps

Bad reducer:

type Action =
  | { type: 'setLoading'; value: boolean }
  | { type: 'setError'; value: string | null }
  | { type: 'setData'; value: Data | null };

Ini hanya useState yang dipindahkan ke file lain.

Good reducer:

type Action =
  | { type: 'loadRequested'; requestId: string }
  | { type: 'loadSucceeded'; requestId: string; data: Data }
  | { type: 'loadFailed'; requestId: string; error: string }
  | { type: 'retryRequested'; requestId: string };

Perbedaannya:

Setter-style reducerEvent-style reducer
Menyatakan field apa yang diubahMenyatakan kejadian/intensi apa yang terjadi
Mudah membuat illegal stateLebih mudah menjaga transition legality
Sulit diauditMudah dilog sebagai event
Logic tetap tersebarLogic terkonsolidasi
Nama action teknisNama action bermakna domain/UI

Reducer architecture dimulai dari event, bukan field.


2. The Reducer Contract

Reducer harus memenuhi kontrak berikut:

Reducer(state, event) must be:
- pure
- deterministic
- synchronous
- immutable in result semantics
- free from side effects
- responsible for transition legality

Reducer tidak boleh:

- fetch API
- mutate DOM
- write localStorage
- call analytics
- navigate route
- generate random IDs internally
- read current time internally
- mutate existing state object

Kalau butuh waktu, random ID, network, analytics, atau navigation, lakukan di command handler/event handler/effect, lalu kirim event hasilnya ke reducer.


3. Mental Model

Reducer tidak melakukan effect. Reducer menerima hasil effect sebagai event.


4. When to Use useReducer

Use reducer when:

- multiple state fields change together
- update logic depends on previous state
- illegal states are possible with independent setters
- event names are clearer than setter names
- transitions deserve tests
- you need request identity guards
- you want to migrate later to context/store/machine without rewriting UI semantics

Do not use reducer just because:

- there are three useState calls
- someone said reducers are more scalable
- you want to copy Redux style ceremony locally

The reason for reducer is transition complexity, not line count.


5. Build a Reducer from a Real Problem

Problem: an approval button has states:

idle
confirming
submitting
succeeded
failed

Naive state:

const [isConfirmOpen, setConfirmOpen] = useState(false);
const [isSubmitting, setSubmitting] = useState(false);
const [isSuccess, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);

This allows nonsense:

confirm modal open while success is true
submitting true while error exists from previous request
success true and error non-null

Reducer state:

type ApprovalState =
  | { status: 'idle' }
  | { status: 'confirming' }
  | { status: 'submitting'; requestId: string }
  | { status: 'succeeded'; receiptId: string }
  | { status: 'failed'; error: string };

Events:

type ApprovalEvent =
  | { type: 'approveClicked' }
  | { type: 'confirmCancelled' }
  | { type: 'confirmAccepted'; requestId: string }
  | { type: 'approveSucceeded'; requestId: string; receiptId: string }
  | { type: 'approveFailed'; requestId: string; error: string }
  | { type: 'retryClicked'; requestId: string }
  | { type: 'resetClicked' };

Reducer:

function approvalReducer(
  state: ApprovalState,
  event: ApprovalEvent
): ApprovalState {
  switch (event.type) {
    case 'approveClicked': {
      if (state.status !== 'idle' && state.status !== 'failed') return state;
      return { status: 'confirming' };
    }

    case 'confirmCancelled': {
      if (state.status !== 'confirming') return state;
      return { status: 'idle' };
    }

    case 'confirmAccepted': {
      if (state.status !== 'confirming') return state;
      return { status: 'submitting', requestId: event.requestId };
    }

    case 'approveSucceeded': {
      if (state.status !== 'submitting') return state;
      if (state.requestId !== event.requestId) return state;
      return { status: 'succeeded', receiptId: event.receiptId };
    }

    case 'approveFailed': {
      if (state.status !== 'submitting') return state;
      if (state.requestId !== event.requestId) return state;
      return { status: 'failed', error: event.error };
    }

    case 'retryClicked': {
      if (state.status !== 'failed') return state;
      return { status: 'submitting', requestId: event.requestId };
    }

    case 'resetClicked': {
      return { status: 'idle' };
    }

    default: {
      return assertNever(event);
    }
  }
}

function assertNever(value: never): never {
  throw new Error(`Unhandled event: ${JSON.stringify(value)}`);
}

Notice what changed:

- impossible states are structurally removed
- stale async responses are ignored by requestId
- event names describe user/system intent
- transitions are local and testable

6. Reducer as Transition Table

A reducer can be visualized as a transition table.

Current StateEventNext StateRule
idleapproveClickedconfirminguser starts approval
confirmingconfirmCancelledidleuser aborts
confirmingconfirmAcceptedsubmittingrequest begins
submittingapproveSucceeded/same requestIdsucceededlatest request wins
submittingapproveFailed/same requestIdfailedlatest request wins
failedretryClickedsubmittingretry allowed
anyresetClickedidleexplicit reset

And as state diagram:

If you cannot draw the state diagram, your reducer is probably still just a bag of setters.


7. Event Naming

Good event names are past-tense facts or clear user intents.

WeakBetter
setOpendialogOpened / openRequested
setLoadingsaveRequested
setErrorsaveFailed
setDataloadSucceeded
updateFieldfieldChanged
toggleselectionToggled

Use naming style intentionally:

- User intent: approveClicked, filterSubmitted, tabSelected
- System result: loadSucceeded, saveFailed, timeoutReached
- Domain event: invoiceApproved, caseEscalated
- Command request: approvalRequested, retryRequested

Do not mix them casually. A reducer event should represent something the state machine can respond to.


8. Reducer State Shape

State shape should encode valid states directly.

Bad:

type State = {
  isLoading: boolean;
  isError: boolean;
  isSuccess: boolean;
  data: Data | null;
  error: string | null;
};

Better:

type State =
  | { status: 'idle' }
  | { status: 'loading'; requestId: string }
  | { status: 'success'; data: Data }
  | { status: 'error'; error: string };

A discriminated union is a state invariant encoded in the type system.


9. Action Payload Design

Payload should contain the information needed to transition, not random UI details.

Bad:

{ type: 'change', payload: event }

The reducer should not know DOM events.

Better:

{ type: 'fieldChanged', field: 'email', value: 'a@b.com' }

Bad:

{ type: 'approveSucceeded', response }

Better:

{ type: 'approveSucceeded', requestId, receiptId }

Payload is a reducer API. Keep it small, explicit, and serializable when possible.


10. Reducer Purity and Side Effect Boundary

Bad:

function reducer(state: State, event: Event): State {
  switch (event.type) {
    case 'submitted':
      localStorage.setItem('draft', JSON.stringify(state.draft));
      return { ...state, status: 'submitting' };
  }
}

Better:

function Component() {
  const [state, dispatch] = useReducer(reducer, initialState);

  async function handleSubmit() {
    const requestId = crypto.randomUUID();
    dispatch({ type: 'submitRequested', requestId });

    try {
      await saveDraft(state.draft);
      dispatch({ type: 'submitSucceeded', requestId });
    } catch (error) {
      dispatch({ type: 'submitFailed', requestId, error: toMessage(error) });
    }
  }
}

Even better, isolate command handler:

function useApprovalController(caseId: string) {
  const [state, dispatch] = useReducer(approvalReducer, { status: 'idle' });

  const approve = async () => {
    const requestId = crypto.randomUUID();
    dispatch({ type: 'confirmAccepted', requestId });

    try {
      const receipt = await approveCase(caseId);
      dispatch({ type: 'approveSucceeded', requestId, receiptId: receipt.id });
    } catch (error) {
      dispatch({ type: 'approveFailed', requestId, error: toMessage(error) });
    }
  };

  return { state, dispatch, approve };
}

The reducer remains pure. The controller owns effects.


11. Lazy Initial State

Use lazy initialization when initial state is expensive or derived once from props/storage.

function init(raw: InitialInput): State {
  return {
    status: 'editing',
    draft: normalizeDraft(raw),
    touched: {},
  };
}

const [state, dispatch] = useReducer(reducer, props.initialInput, init);

Important distinction:

initializer runs to create initial state
it does not automatically re-run when props change

If props should reset the reducer, use component identity/key or explicit reset event.

<Editor key={documentId} document={document} />

or:

dispatch({ type: 'documentChanged', document });

Choose intentionally.


12. Selectors

Do not make every component inspect raw reducer state.

Raw state:

state.status === 'submitting' || state.status === 'confirming'

Selector:

function selectCanCancel(state: ApprovalState) {
  return state.status === 'confirming' || state.status === 'submitting';
}

function selectSubmitDisabled(state: ApprovalState) {
  return state.status === 'submitting' || state.status === 'succeeded';
}

Selectors give you:

- stable vocabulary
- reusable view derivation
- better tests
- less duplication
- easier migration to external store

Selectors should be pure calculations.


13. Reducer + View Mapping

Avoid spreading transition logic into JSX.

Weak:

{state.status === 'idle' || state.status === 'failed' ? (
  <Button onClick={() => dispatch({ type: 'approveClicked' })}>Approve</Button>
) : null}

Better:

const canStartApproval = selectCanStartApproval(state);

return canStartApproval ? (
  <Button onClick={() => dispatch({ type: 'approveClicked' })}>Approve</Button>
) : null;

Even better for complex screens:

const vm = createApprovalViewModel(state);

return <ApprovalPanel {...vm} onEvent={dispatch} />;

A view model is not always needed, but when render logic becomes a maze of status === ..., it creates a useful boundary.


14. Reducer Testing

Reducer tests should read like transition specs.

describe('approvalReducer', () => {
  it('moves idle to confirming when approve is clicked', () => {
    const next = approvalReducer(
      { status: 'idle' },
      { type: 'approveClicked' }
    );

    expect(next).toEqual({ status: 'confirming' });
  });

  it('ignores stale success from older request', () => {
    const next = approvalReducer(
      { status: 'submitting', requestId: 'new' },
      { type: 'approveSucceeded', requestId: 'old', receiptId: 'r1' }
    );

    expect(next).toEqual({ status: 'submitting', requestId: 'new' });
  });
});

Test categories:

- legal transition
- illegal transition
- reset transition
- stale async result
- invariant preservation
- exhaustive event handling

Reducer tests are cheap. Use them.


15. Exhaustiveness

Use TypeScript to make missed events visible.

function assertNever(x: never): never {
  throw new Error(`Unexpected object: ${JSON.stringify(x)}`);
}

function reducer(state: State, event: Event): State {
  switch (event.type) {
    case 'started':
      return { status: 'running' };
    case 'stopped':
      return { status: 'stopped' };
    default:
      return assertNever(event);
  }
}

When a new event is added, the compiler can force you to decide how the reducer handles it.


16. Split Reducers Carefully

Split reducer logic when the domain has separate sub-machines.

Do not split merely by field.

Weak split:

loadingReducer
errorReducer
dataReducer

Better split:

selectionReducer
filterReducer
bulkActionReducer
validationReducer

A split reducer should own a meaningful sub-domain or sub-workflow.

Example:

function pageReducer(state: PageState, event: PageEvent): PageState {
  return {
    filters: filterReducer(state.filters, event),
    selection: selectionReducer(state.selection, event),
    bulkAction: bulkActionReducer(state.bulkAction, event),
  };
}

But be careful: if a transition must update multiple slices atomically, the parent reducer should coordinate it explicitly.


17. Nested State and Immutability

React reducers must return new state when state changes.

Without Immer:

case 'fieldChanged':
  return {
    ...state,
    draft: {
      ...state.draft,
      [event.field]: event.value,
    },
    touched: {
      ...state.touched,
      [event.field]: true,
    },
  };

With Redux Toolkit, createSlice lets you write mutating syntax because it uses Immer internally.

const slice = createSlice({
  name: 'draft',
  initialState,
  reducers: {
    fieldChanged(state, action: PayloadAction<{ field: string; value: string }>) {
      state.draft[action.payload.field] = action.payload.value;
      state.touched[action.payload.field] = true;
    },
  },
});

Do not assume local React useReducer has Immer. It does not.

If you want Immer locally, introduce it explicitly and understand the dependency.


18. Async Reducer Pattern

Reducer handles async lifecycle events. It does not perform async work.

Pattern:

async function handleSave() {
  const requestId = crypto.randomUUID();
  dispatch({ type: 'saveRequested', requestId });

  try {
    const result = await save(state.draft);
    dispatch({ type: 'saveSucceeded', requestId, result });
  } catch (error) {
    dispatch({ type: 'saveFailed', requestId, error: toMessage(error) });
  }
}

Reducer guard:

case 'saveSucceeded': {
  if (state.status !== 'saving') return state;
  if (state.requestId !== event.requestId) return state;
  return { status: 'saved', result: event.result };
}

This prevents stale response overwrite.


19. Reducer with AbortController

For cancellable requests:

function useSearchController() {
  const [state, dispatch] = useReducer(searchReducer, { status: 'idle' });
  const abortRef = useRef<AbortController | null>(null);

  async function search(query: string) {
    abortRef.current?.abort();

    const controller = new AbortController();
    abortRef.current = controller;
    const requestId = crypto.randomUUID();

    dispatch({ type: 'searchRequested', query, requestId });

    try {
      const results = await searchApi(query, { signal: controller.signal });
      dispatch({ type: 'searchSucceeded', requestId, results });
    } catch (error) {
      if (controller.signal.aborted) {
        dispatch({ type: 'searchCancelled', requestId });
        return;
      }

      dispatch({ type: 'searchFailed', requestId, error: toMessage(error) });
    }
  }

  return { state, search };
}

Ownership:

AbortController lives in ref because it is mutable external control state.
Request lifecycle lives in reducer because it affects render.

20. Reducer + Context Architecture

For a complex screen:

const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch<Event> | null>(null);

export function FeatureProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(featureReducer, initialState);

  return (
    <StateContext.Provider value={state}>
      <DispatchContext.Provider value={dispatch}>
        {children}
      </DispatchContext.Provider>
    </StateContext.Provider>
  );
}

export function useFeatureState() {
  const value = useContext(StateContext);
  if (value === null) throw new Error('useFeatureState must be used inside FeatureProvider');
  return value;
}

export function useFeatureDispatch() {
  const value = useContext(DispatchContext);
  if (value === null) throw new Error('useFeatureDispatch must be used inside FeatureProvider');
  return value;
}

Split state and dispatch context because dispatch identity is stable, while state changes.

Still remember: all consumers of StateContext rerender when the state value changes. If this becomes a problem, move to selectors or external store.


21. Reducer Is a Migration-Friendly Boundary

A well-designed local reducer can migrate without changing component semantics.

The key is event vocabulary.

If your UI dispatches domain-level events, implementation can change behind the boundary.


22. Reducer vs State Machine

Reducer is enough when:

- transition graph is small
- async side effects are simple
- nested/parallel states are limited
- team can maintain transition table manually

Move toward formal state machine when:

- nested states appear
- parallel states appear
- invoked services have lifecycle
- guards/actions need first-class modeling
- workflow must be visualized or audited
- illegal transitions are costly

A reducer is often the training ground for state machine thinking.


23. Reducer vs External Store

Reducer answers:

How should this state transition?

External store answers:

Where should this state live and how should components subscribe to it?

They are not opposites.

You can have:

- reducer inside component
- reducer inside context provider
- reducer inside Redux slice
- reducer-like actions inside Zustand store
- reducer as transition function inside a custom store

The transition model and storage/subscription model are separate design choices.


24. Common Failure Modes

24.1 Action Explosion

Symptom:

20 action types, most named setX

Cause:

Reducer used as setter registry.

Fix:

Re-group actions by user intent or domain event.

24.2 Impossible State Still Possible

Symptom:

type State = {
  status: 'idle' | 'loading' | 'success' | 'error';
  data: Data | null;
  error: string | null;
};

This still allows:

status=success with error non-null
status=error with data non-null

Fix:

type State =
  | { status: 'idle' }
  | { status: 'loading'; requestId: string }
  | { status: 'success'; data: Data }
  | { status: 'error'; error: string };

24.3 Side Effects Inside Reducer

Symptom:

reducer writes storage, logs analytics, creates IDs, or fetches

Fix:

Move effect to command handler or effect boundary; reducer receives result event.

24.4 Reducer Becomes Global Junk Drawer

Symptom:

one reducer handles unrelated features because they share a page

Fix:

Split by ownership and transition coupling, not file convenience.

24.5 Unhandled Stale Async Response

Symptom:

Older request finishes after newer request and overwrites UI.

Fix:

Use requestId or abort boundary. Reducer ignores stale result event.

25. Production Checklist

Before merging reducer architecture:

[ ] Events represent user/system intent, not raw setters.
[ ] State shape prevents impossible states.
[ ] Reducer is pure and synchronous.
[ ] Side effects live outside reducer.
[ ] Async events carry request identity when needed.
[ ] Illegal transitions are explicitly handled.
[ ] Reset behavior is explicit.
[ ] Selectors hide raw state details from view code.
[ ] Tests cover legal transitions, illegal transitions, stale async results, and reset.
[ ] The reducer can migrate to context/store/machine without changing event vocabulary.

26. Exercises

Exercise 1 — Convert Booleans to Union State

Refactor:

type State = {
  isOpen: boolean;
  isSubmitting: boolean;
  isSuccess: boolean;
  error: string | null;
};

Into a discriminated union for a modal submit workflow.

Exercise 2 — Build Transition Table

For this workflow:

idle → editing → validating → submitting → submitted
validating → invalid
submitting → failed
failed → submitting

Write:

- State union
- Event union
- Transition table
- Reducer
- Tests for stale validation response

Exercise 3 — Extract Command Handler

Given a reducer that calls API inside reducer, refactor into:

- pure reducer
- command handler hook
- requestId guard
- tests

27. Closing Model

Reducer architecture is the bridge between ordinary state and formal workflow modeling.

useState answers: what value should I store?
useReducer answers: what transitions are legal?
state machine answers: what workflow exists, including nested/parallel states and invoked services?
external store answers: how should this state be shared and subscribed to?
server-state cache answers: how should remote authority, freshness, and invalidation work?

A top-tier React engineer does not use reducer everywhere.

They use reducer when state changes need a protocol.


References

Lesson Recap

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