Learning path · Level 10 · Tutorial 5

Model Client-State Transitions with Reducers

Model related client-state changes with a typed reducer. Keep transitions predictable and application rules consistent when several user actions affect the same state.

  • Level: Intermediate
  • Duration: 18 min
  • 8 steps
Start tutorial Back to the path
Model Client-State Transitions with Reducers

What you will go through

  1. Recognize a Coordinated State Transition Recognize when a feature needs centralized transition rules rather than independent local updates. 2 min
  2. Define the State Contract and Its Rules Define the minimal Comparison Tray state contract, derive availability from it, and recognize snapshots that break its rules. 2 min
  3. Describe Intentions with Typed Actions Define a typed action vocabulary for Comparison Tray interactions and keep browser-event details outside the transition contract. 2 min
  4. Implement Rule-Preserving Transitions Implement reducer branches that preserve Comparison Tray rules, including blocked actions and coordinated removal behavior. 4 min
  5. Keep Reducer Execution Pure Keep Comparison Tray transitions repeatable by separating pure reducer calculations from external work. 2 min
  6. Connect the Reducer to the Interface Wire the Comparison Tray reducer into a local React interface, dispatch semantic actions from controls, and render the resulting local state. 3 min
  7. Trace an Action Sequence Trace reducer actions as one connected state timeline, including guards and the transition that changes the visible comparison panel. 2 min
  8. Apply and Review the Complete Model Apply the complete Comparison Tray model, verify a coordinated removal, and choose the right boundary for reducer state. 2 min

What you will learn

  • Define a state contract and discriminated-union actions for a coordinated interaction.
  • Implement a pure reducer that preserves the feature's state rules.
  • Connect user interactions to useReducer through semantic dispatch calls.
  • Trace a sequence of actions and compare the resulting state with the expected outcome.
  • Explain when a reducer is useful and when local useState remains sufficient.

Before you start

  • Organize React Features by Responsibility
  • Update Component State from User Events
  • Narrow Unions and Nullable Values

Step 1 of 8

Recognize a Coordinated State Transition

Recognize when a feature needs centralized transition rules rather than independent local updates.

See the coordinated change

One interaction, one transition

The Comparison Tray lets a user select local products—Notebook, Pen, Mug, and Lamp—then compare selected items, remove items, or clear the tray.

A reducer is a function that maps a current state and an action (a meaningful user intention) to a next state. It is useful when one user intention must consistently apply related rules across the feature.

A flow diagram shows current state, an itemRemoved action, a reducer, and next state. Removing one of two selected items closes the comparison.

A reducer makes the whole state transition explicit: current state + action → next state.

Identify the rule, not just the fields

Why coordination matters

Several state values alone do not require a reducer. A simple, independent value can remain easy to manage with local useState.

Choose a reducer when transitions are coordinated: one action can affect more than one part of the feature state, and the same rules must hold no matter where that action originates.

Independent value or coordinated feature?

Compare the two interaction shapes.

The deciding factor is related transition rules, not the number of values.
InteractionWhat changesSuitable approach
Show or hide one help hintOne independent booleanLocal useState is sufficient
Remove a tray selectionSelection changes; comparison may also need to closeReducer centralizes the rule
Clear the traySelections and comparison visibility reset togetherReducer centralizes the rule

Tip

Keep the scope clear

A reducer changes how updates are expressed and where rules are applied. It does not automatically make the tray state global, and it does not inherently improve rendering performance. The Comparison Tray can still be owned by one component.

Practice: spot a coordinated transition

Check your judgment

Which interaction is the stronger reason to use a reducer?

Step 2 of 8

Define the State Contract and Its Rules

Define the minimal Comparison Tray state contract, derive availability from it, and recognize snapshots that break its rules.

Choose the state facts

Store facts; derive availability

A state contract names the smallest data the Comparison Tray needs to remember. The tray remembers which product IDs are selected and whether the user explicitly opened comparison.

It does not store the selected count or a separate “can compare” flag. Calculate those values from selectedIds whenever the interface renders.

Two selected products make opening available, but they do not mean that comparison is already open. Opening is an explicit user choice, so isOpen must be stored.

State and derived interface values

A state card with two selected products and a closed comparison leads to a derived count and an available compare control while the comparison panel remains closed.

Selection count and opening permission are derived. Whether the panel is open is stored because it reflects a user choice.

Define the minimal contract

Write the contract

Create src/features/comparison/comparisonReducer.ts. The fixed local catalog gives us a narrow ProductId union, so an unknown string cannot be selected by mistake.

ComparisonState has exactly two fields. initialState starts with no selections and a closed comparison. The capacity is a named rule rather than a repeated magic number.

Comparison Tray state contract

src/features/comparison/comparisonReducer.ts

typescript
export type ProductId =
  | 'notebook'
  | 'pen'
  | 'mug'
  | 'lamp';

export type ComparisonState = {
  selectedIds: ProductId[];
  isOpen: boolean;
};

export const MAX_SELECTION = 3;

export const initialState: ComparisonState = {
  selectedIds: [],
  isOpen: false
};

Tip

Derive, do not duplicate

Later, derive display values from the state:

  • selectedIds.length supplies text such as 2 of 3 selected.
  • selectedIds.length >= 2 tells the interface whether opening is currently permitted.

Neither value belongs in ComparisonState: each duplicates information already available from the selection.

Describe valid state snapshots

State rules as invariants

Before implementing transitions, write the invariants: conditions that every valid snapshot must satisfy.

  1. Every selected ID is unique.
  2. The selection contains no more than three products.
  3. If isOpen is true, at least two products are selected.

The reverse of rule 3 is not required. A tray with two or three selections may still be closed, because permission to open is not the same as an open comparison.

Check snapshots, not just controls

Read each record as a possible tray snapshot. A snapshot is invalid when it breaks a rule, regardless of how it might have been produced.

Examples of valid and invalid Comparison Tray snapshots
SnapshotValid?Reason
{ selectedIds: ['notebook', 'pen'], isOpen: false }YesTwo selections may remain closed
{ selectedIds: ['notebook', 'notebook'], isOpen: false }NoA selected ID is duplicated
{ selectedIds: ['notebook', 'pen', 'mug', 'lamp'], isOpen: false }NoFour selections exceed capacity
{ selectedIds: ['mug'], isOpen: true }NoAn open comparison needs two selections

Apply the contract

Complete the state contract

Complete the missing parts of the Comparison Tray state contract.

typescript
export type ComparisonState = {
  selectedIds: ;
  isOpen: ;
};

export const MAX_SELECTION = ;

export const initialState: ComparisonState = {
  selectedIds: [],
  isOpen: 
};

Check every invariant

Classify a state snapshot

Which snapshot satisfies every Comparison Tray invariant?

Step 3 of 8

Describe Intentions with Typed Actions

Define a typed action vocabulary for Comparison Tray interactions and keep browser-event details outside the transition contract.

Name what the user intends

Intentions, not assignments

A reducer action names an application intention. It does not describe a low-level field assignment.

For the Comparison Tray, “open the comparison” is meaningful even if the rule for opening later involves more than one state field. Model that intention as comparisonOpened, not as an instruction such as “set isOpen to true.”

This gives every transition a vocabulary that matches what the user is trying to do: add an item, remove an item, open, close, or clear.

From interaction to intention

Diagram showing current state, an itemAdded action with a product ID, a reducer, and next state; a DOM click event is kept outside the action.

An action carries the application data needed for a transition, not the browser event that led to it.

Write the typed action vocabulary

ComparisonAction contract

In src/features/comparison/comparisonReducer.ts, define one union for every permitted Comparison Tray intention. ProductId is the catalog identifier type defined with the tray state contract.

typescript
export type ComparisonAction =
  | { type: 'itemAdded'; productId: ProductId }
  | { type: 'itemRemoved'; productId: ProductId }
  | { type: 'comparisonOpened' }
  | { type: 'comparisonClosed' }
  | { type: 'selectionCleared' };

One type field, precise payloads

Each union member has a literal type value. That shared field is the discriminant: TypeScript can use it to determine which fields belong to a particular action.

Only adding and removing identify a product. Opening, closing, and clearing already express complete intentions, so they carry no extra payload.

Action shapes

These are the action-object shapes produced for the tray's controls.

Payloads appear only when the intention needs a product identity.
User intentionAction objectPayload
Add Notebook{ type: 'itemAdded', productId: 'notebook' }productId
Remove Notebook{ type: 'itemRemoved', productId: 'notebook' }productId
Compare{ type: 'comparisonOpened' }None
Close comparison{ type: 'comparisonClosed' }None
Clear selection{ type: 'selectionCleared' }None

Separate interface details from application data

Let the discriminant enforce shape

The discriminant prevents vague or malformed objects. Once an action is identified as itemAdded or itemRemoved, its productId is available as a ProductId. For an action identified as comparisonOpened, there is no product ID to read.

Likewise, an object such as { type: 'comparisonOpened', productId: 'notebook' } does not match this contract. Type checking catches that unnecessary payload when the object is written as an action.

Tip

Keep DOM details out

A browser event belongs to the interface layer. The interface may read interaction details to determine a product ID, but the action contract receives only application data such as productId. Do not put an event object, a button element, or another DOM value into ComparisonAction.

Practice: express each tray intention

Complete the semantic action contract

Complete the discriminants for this action contract. Preserve the payload structure shown: do not add DOM events or extra payloads.

typescript
export type ComparisonAction =
  // A product becomes selected.
  | { type: ; productId: ProductId }
  // A selected product is removed.
  | { type: ; productId: ProductId }
  // The user requests the comparison view.
  | { type:  }
  // The user dismisses the comparison view.
  | { type:  }
  // The user resets the tray selection.
  | { type:  };

Step 4 of 8

Implement Rule-Preserving Transitions

Implement reducer branches that preserve Comparison Tray rules, including blocked actions and coordinated removal behavior.

Use the reducer as the rule boundary

One rule-enforcing transition function

comparisonReducer is the rule boundary for the Comparison Tray. It receives the current state and a semantic action, then returns the next state.

A branch returns a new state object only when the transition is allowed. For a blocked or irrelevant action, return the existing state. This keeps the rules true even if an action reaches the reducer without the expected interface control.

For this tray:

  • Adding is blocked for an existing ID or a full selection.
  • Removing an existing ID can also close an open comparison.
  • Opening is blocked until two products are selected.
  • Closing keeps the selection.
  • Clearing restores the empty, closed state.

One action can coordinate two fields

A diagram shows itemRemoved flowing from an open tray with Notebook and Pen through a reducer to a closed tray with only Notebook.

Removing a selected product can change both selectedIds and isOpen in one rule-preserving transition.

Implement the addition branch

Add without mutation

Place comparisonReducer below the state and action contracts in src/features/comparison/comparisonReducer.ts. The switch narrows action by its type.

The itemAdded branch rejects duplicate and full-tray requests. When it accepts an ID, array spread creates a new selection in insertion order. Object spread retains the current isOpen value, so adding an item does not silently open or close the comparison.

Guard duplicates and capacity

Addition branch inside comparisonReducer

typescript
case 'itemAdded':
  if (
    state.selectedIds.includes(action.productId) ||
    state.selectedIds.length === MAX_SELECTION
  ) {
    return state;
  }
  return {
    ...state,
    selectedIds: [...state.selectedIds, action.productId]
  };

Tip

Keep the input unchanged

Do not use push on state.selectedIds. Array spread creates a new array and leaves the input state unchanged. The guard also protects the invariant when an action is dispatched without a disabled button.

Preserve every invariant in each branch

Remove, open, close, and clear

Removal needs a coordinated result. First ignore an ID that is not selected. For a real removal, create the filtered array, then use its length to decide whether an open comparison must close.

The other branches preserve unrelated fields. Opening requires at least two selections, closing preserves the selection, and clearing restores the known empty, closed starting state.

Implement the remaining branches

Continue the same switch (action.type) block.

typescript
case 'itemRemoved': {
  if (!state.selectedIds.includes(action.productId)) return state;
  const selectedIds = state.selectedIds.filter(
    (id) => id !== action.productId
  );
  return {
    selectedIds,
    isOpen: selectedIds.length >= 2 ? state.isOpen : false
  };
}

case 'comparisonOpened':
  if (state.selectedIds.length < 2 || state.isOpen) return state;
  return { ...state, isOpen: true };

case 'comparisonClosed':
  return state.isOpen ? { ...state, isOpen: false } : state;

case 'selectionCleared':
  return state.selectedIds.length === 0 && !state.isOpen
    ? state
    : initialState;

Check the coordinated outcomes

Examples of accepted and blocked transitions.

Each next state still satisfies the Comparison Tray invariants.
Current stateActionNext state
{ selectedIds: ['notebook'], isOpen: false }{ type: 'comparisonOpened' }{ selectedIds: ['notebook'], isOpen: false }
{ selectedIds: ['notebook', 'pen'], isOpen: false }{ type: 'comparisonOpened' }{ selectedIds: ['notebook', 'pen'], isOpen: true }
{ selectedIds: ['notebook', 'pen'], isOpen: true }{ type: 'itemRemoved', productId: 'pen' }{ selectedIds: ['notebook'], isOpen: false }

Apply the guards and coordinated return

Predict a blocked addition

What does this action return when applied to the current state?

Current state: { selectedIds: ['notebook', 'pen', 'mug'], isOpen: false }

Action: { type: 'itemAdded', productId: 'lamp' }

Complete guarded transitions

Complete the capacity guard, the blocked-transition return, and the coordinated removal return.

typescript
case 'itemAdded':
  if (
    state.selectedIds.includes(action.productId) ||
    state.selectedIds.length === 
  ) {
    return ;
  }
  return {
    ...state,
    selectedIds: [...state.selectedIds, action.productId]
  };

case 'itemRemoved': {
  if (!state.selectedIds.includes(action.productId)) return state;
  const selectedIds = state.selectedIds.filter(
    (id) => id !== action.productId
  );
  return {
    selectedIds,
    isOpen: selectedIds.length < 2 ? false : 
  };
}

Step 5 of 8

Keep Reducer Execution Pure

Keep Comparison Tray transitions repeatable by separating pure reducer calculations from external work.

Calculate, do not cause effects

A reducer only calculates the next state

A reducer is a calculation: it receives the current state and one semantic action, then returns the next state. For the same inputs, it should always produce the same result.

Keep it self-contained. Do not mutate state or action, write to browser storage, change the DOM, start a request, read the clock, or use random values. Those operations depend on or change something outside the reducer.

State transition as a pure calculation

A diagram shows current state and an itemRemoved action entering a reducer, which returns a new next state.

The reducer derives the next state from its two inputs; it does not perform browser or network work.

Separate calculations from external work

An external write does not belong in a reducer

The following branch is deliberately incorrect. The storage write is an external effect, so it must not run while React is evaluating a state transition.

Incorrect: writing to storage during a transition

Deliberately incorrect reducer branch—do not copy this pattern.

TypeScript
case 'selectionCleared':
  localStorage.setItem('comparison-tray', 'cleared');
  return initialState;

Put each responsibility in the right place

Use the boundary that owns the work. The reducer remains the rule-enforcement boundary for state transitions.

External work happens outside reducer execution.
WorkAppropriate boundaryReducer responsibility
Calculate a valid next tray stateReducerReturn it synchronously
Start work from a user clickEvent handlerDispatch an action when appropriate
Synchronize with an external systemExisting EffectDo not calculate the next tray state
Read time or generate a random valueOutside the reducerDispatch only ordinary needed data

Return state immediately

Keep asynchronous work around dispatch

An event handler can own user-triggered external work. If that work is asynchronous, it may wait for completion and then dispatch an ordinary action with the relevant application data. The reducer itself still returns a ComparisonState immediately—not a promise.

Effects remain for synchronizing React with external systems. They are not a substitute for calculating the next Comparison Tray state.

Synchronous reducer return

A reducer branch computes and returns the state immediately.

TypeScript
case 'comparisonClosed':
  return {
    ...state,
    isOpen: false
  };

Warning

Do not rely on one reducer call

React development checks can evaluate a reducer more than once. Treat reducer execution as repeatable calculation, never as a once-only callback for a request, storage write, DOM change, timer, or analytics event.

Check the execution boundary

Place each operation at the correct boundary

A draft has placed all three operations inside comparisonReducer. Match each operation to the boundary where it belongs.

Tap an item and then its matching pair.

Step 6 of 8

Connect the Reducer to the Interface

Wire the Comparison Tray reducer into a local React interface, dispatch semantic actions from controls, and render the resulting local state.

Bind state and render from it

Bind local state to the reducer

ComparisonTray owns its local state. Inside the component, useReducer connects the existing comparisonReducer and initialState to two values:

  • state is the current tray snapshot used for rendering.
  • dispatch sends a semantic action to the reducer.

Importing a reducer module centralizes transition rules, but it does not create shared storage. Each mounted ComparisonTray has its own state.

Render the reducer-owned snapshot

Diagram showing current state, an itemRemoved action, a reducer, and next state for the Comparison Tray.

The interface renders the next state returned after each dispatched action.

Tip

Keep guards in the reducer

Disabled buttons communicate what is currently unavailable, such as opening with fewer than two selections. They improve the interface, but they are not the rule boundary: the reducer must still reject an invalid action dispatched another way.

Keep the transition module independent

Comparison Tray reducer

Create src/features/comparison/comparisonReducer.ts, or confirm that your existing file contains this complete reducer implementation. It remains independent of React; the component will import its named exports.

typescript
export type ProductId =
  | 'notebook'
  | 'pen'
  | 'mug'
  | 'lamp';

export type ComparisonState = {
  selectedIds: ProductId[];
  isOpen: boolean;
};

export const MAX_SELECTION = 3;

export const initialState: ComparisonState = {
  selectedIds: [],
  isOpen: false
};

export type ComparisonAction =
  | { type: 'itemAdded'; productId: ProductId }
  | { type: 'itemRemoved'; productId: ProductId }
  | { type: 'comparisonOpened' }
  | { type: 'comparisonClosed' }
  | { type: 'selectionCleared' };

export function comparisonReducer(
  state: ComparisonState,
  action: ComparisonAction
): ComparisonState {
  switch (action.type) {
    case 'itemAdded':
      if (
        state.selectedIds.includes(action.productId) ||
        state.selectedIds.length === MAX_SELECTION
      ) {
        return state;
      }
      return {
        ...state,
        selectedIds: [...state.selectedIds, action.productId]
      };

    case 'itemRemoved': {
      if (!state.selectedIds.includes(action.productId)) return state;
      const selectedIds = state.selectedIds.filter(
        (id) => id !== action.productId
      );
      return {
        selectedIds,
        isOpen: selectedIds.length >= 2 ? state.isOpen : false
      };
    }

    case 'comparisonOpened':
      if (state.selectedIds.length < 2 || state.isOpen) return state;
      return { ...state, isOpen: true };

    case 'comparisonClosed':
      return state.isOpen ? { ...state, isOpen: false } : state;

    case 'selectionCleared':
      return state.selectedIds.length === 0 && !state.isOpen
        ? state
        : initialState;
  }
}

Assemble the local interface

ComparisonTray interface

Create src/features/comparison/ComparisonTray.tsx. This component derives display values during rendering and dispatches only application data from its controls.

tsx
import { useReducer } from 'react';
import {
  comparisonReducer,
  initialState,
  MAX_SELECTION,
  type ProductId
} from './comparisonReducer';

const products: { id: ProductId; name: string }[] = [
  { id: 'notebook', name: 'Notebook' },
  { id: 'pen', name: 'Pen' },
  { id: 'mug', name: 'Mug' },
  { id: 'lamp', name: 'Lamp' }
];

export function ComparisonTray() {
  const [state, dispatch] = useReducer(comparisonReducer, initialState);
  const selectedNames = state.selectedIds.map(
    (id) => products.find((product) => product.id === id)?.name ?? id
  );
  const canAdd = state.selectedIds.length < MAX_SELECTION;
  const canOpen = state.selectedIds.length >= 2 && !state.isOpen;

  return (
    <section aria-labelledby="comparison-tray-heading">
      <h1 id="comparison-tray-heading">Comparison Tray</h1>
      <p>{state.selectedIds.length} of {MAX_SELECTION} selected</p>
      <p>Selected: {selectedNames.join(', ') || 'None'}</p>

      <h2>Products</h2>
      {products.map((product) => {
        const selected = state.selectedIds.includes(product.id);
        return (
          <div key={product.id}>
            <span>{product.name}</span>{' '}
            <button
              disabled={selected || !canAdd}
              onClick={() =>
                dispatch({ type: 'itemAdded', productId: product.id })
              }
            >
              Add {product.name}
            </button>{' '}
            <button
              disabled={!selected}
              onClick={() =>
                dispatch({ type: 'itemRemoved', productId: product.id })
              }
            >
              Remove {product.name}
            </button>
          </div>
        );
      })}

      <p>
        <button
          disabled={!canOpen}
          onClick={() => dispatch({ type: 'comparisonOpened' })}
        >
          Compare
        </button>{' '}
        <button
          disabled={!state.isOpen}
          onClick={() => dispatch({ type: 'comparisonClosed' })}
        >
          Close comparison
        </button>{' '}
        <button
          disabled={state.selectedIds.length === 0}
          onClick={() => dispatch({ type: 'selectionCleared' })}
        >
          Clear selection
        </button>
      </p>

      {state.isOpen && (
        <section aria-labelledby="comparison-heading">
          <h2 id="comparison-heading">Comparison</h2>
          <p>{selectedNames.join(' and ')}</p>
        </section>
      )}
    </section>
  );
}

Mount the feature locally

Replace the contents of src/App.tsx with this local mounting example. Run your existing Vite app after all three files are in place.

tsx
import { ComparisonTray } from './features/comparison/ComparisonTray';

export default function App() {
  return <ComparisonTray />;
}
Expected output
Initial render
Comparison Tray
0 of 3 selected
Selected: None
No Comparison section is visible.

After Add Notebook
1 of 3 selected
Selected: Notebook
No Comparison section is visible.

After Add Pen, then Compare
2 of 3 selected
Selected: Notebook, Pen
Comparison
Notebook and Pen

After Remove Pen
1 of 3 selected
Selected: Notebook
The Comparison section is no longer visible.

After Clear selection
0 of 3 selected
Selected: None
No Comparison section is visible.

Expected browser-visible result. Button availability also changes with the shown state; this is not console output.

Practice semantic wiring

Complete the binding and dispatches

Complete the Hook binding and the semantic dispatches. The handlers send action objects, not DOM events.

tsx
function Controls() {
  const [state, dispatch] = (comparisonReducer, initialState);

  return (
    <>
      <p>{state.selectedIds.length} of {MAX_SELECTION} selected</p>
      <button
        onClick={() =>
          dispatch({ type: , productId: 'notebook' })
        }
      >
        Add Notebook
      </button>
      <button onClick={() => dispatch({ type:  })}>
        Compare
      </button>
      <button
        onClick={() =>
          dispatch({ type: , productId: 'notebook' })
        }
      >
        Remove Notebook
      </button>
      <button onClick={() => dispatch({ type:  })}>
        Clear selection
      </button>
    </>
  );
}

Verify the completed interaction

Verify the visible behavior

After completing the binding and running the local app, perform this sequence: add Notebook, add Pen, choose Compare, remove Pen, then clear the selection. Report the selected names and count after each meaningful change, when the inline Comparison section appears, why it disappears after removal, and what remains after clearing.

Write at least 160 characters (0/160).

Step 7 of 8

Trace an Action Sequence

Trace reducer actions as one connected state timeline, including guards and the transition that changes the visible comparison panel.

Follow one state timeline

Carry each result forward

A trace is a chain, not a set of independent reducer calls. Begin with initialState, pass it with the first action to comparisonReducer, and then pass that returned state into the next call.

A guarded action can return the exact current state. Record that unchanged snapshot and continue from it; do not restart from initialState. At every row, inspect both selectedIds and isOpen, then check the tray rules: IDs stay unique, no more than three are selected, and an open comparison has at least two selections.

A reducer trace is a connected timeline

Diagram showing current state flowing through an action and reducer to next state, plus a guarded duplicate action that leaves the state unchanged.

For every action, the next state becomes the current state for the following reducer call.

Tip

Trace rule

Treat an unchanged return as evidence that a guard worked. It is still a real point in the timeline and still determines what the next action receives.

Compare every returned snapshot

Run a plain TypeScript trace

Create src/features/comparison/traceComparison.ts with this temporary trace. Then, at the top of src/App.tsx, directly after the existing ComparisonTray import, add this side-effect import:

import './features/comparison/traceComparison';

Vite now evaluates the trace module when it loads App.tsx, so inspect the browser console while the app runs. These are ordinary typed calls, not a test framework. Remove the temporary side-effect import from src/App.tsx after checking the trace.

typescript
import {
  comparisonReducer,
  initialState,
  type ComparisonAction
} from './comparisonReducer';

export function runComparisonTrace(): void {
  let state = initialState;

  function trace(action: ComparisonAction): void {
    state = comparisonReducer(state, action);
    console.log(`${JSON.stringify(action)} -> ${JSON.stringify(state)}`);
  }

  trace({ type: 'comparisonOpened' });
  trace({ type: 'itemAdded', productId: 'notebook' });
  trace({ type: 'itemAdded', productId: 'notebook' });
  trace({ type: 'itemAdded', productId: 'pen' });
  trace({ type: 'comparisonOpened' });
  trace({ type: 'itemAdded', productId: 'mug' });
  trace({ type: 'itemAdded', productId: 'lamp' });
  trace({ type: 'itemRemoved', productId: 'lamp' });
  trace({ type: 'itemRemoved', productId: 'pen' });
  trace({ type: 'itemRemoved', productId: 'mug' });
}

runComparisonTrace();
Expected output
{"type":"comparisonOpened"} -> {"selectedIds":[],"isOpen":false}
{"type":"itemAdded","productId":"notebook"} -> {"selectedIds":["notebook"],"isOpen":false}
{"type":"itemAdded","productId":"notebook"} -> {"selectedIds":["notebook"],"isOpen":false}
{"type":"itemAdded","productId":"pen"} -> {"selectedIds":["notebook","pen"],"isOpen":false}
{"type":"comparisonOpened"} -> {"selectedIds":["notebook","pen"],"isOpen":true}
{"type":"itemAdded","productId":"mug"} -> {"selectedIds":["notebook","pen","mug"],"isOpen":true}
{"type":"itemAdded","productId":"lamp"} -> {"selectedIds":["notebook","pen","mug"],"isOpen":true}
{"type":"itemRemoved","productId":"lamp"} -> {"selectedIds":["notebook","pen","mug"],"isOpen":true}
{"type":"itemRemoved","productId":"pen"} -> {"selectedIds":["notebook","mug"],"isOpen":true}
{"type":"itemRemoved","productId":"mug"} -> {"selectedIds":["notebook"],"isOpen":false}

The browser console shows one line for each action. Formatting may vary slightly by browser, but the state snapshots and action order should match.

Read blocked and accepted transitions

Expected transition table

Use the action list and console output together. The table makes the current-to-next relationship explicit, including actions that are blocked because they would break a rule.

The selection order is preserved whenever an item is successfully added or another item is removed.
Current stateActionNext state
{ selectedIds: [], isOpen: false }{ type: 'comparisonOpened' }{ selectedIds: [], isOpen: false }
{ selectedIds: [], isOpen: false }{ type: 'itemAdded', productId: 'notebook' }{ selectedIds: ['notebook'], isOpen: false }
{ selectedIds: ['notebook'], isOpen: false }{ type: 'itemAdded', productId: 'notebook' }{ selectedIds: ['notebook'], isOpen: false }
{ selectedIds: ['notebook'], isOpen: false }{ type: 'itemAdded', productId: 'pen' }{ selectedIds: ['notebook', 'pen'], isOpen: false }
{ selectedIds: ['notebook', 'pen'], isOpen: false }{ type: 'comparisonOpened' }{ selectedIds: ['notebook', 'pen'], isOpen: true }
{ selectedIds: ['notebook', 'pen'], isOpen: true }{ type: 'itemAdded', productId: 'mug' }{ selectedIds: ['notebook', 'pen', 'mug'], isOpen: true }
{ selectedIds: ['notebook', 'pen', 'mug'], isOpen: true }{ type: 'itemAdded', productId: 'lamp' }{ selectedIds: ['notebook', 'pen', 'mug'], isOpen: true }
{ selectedIds: ['notebook', 'pen', 'mug'], isOpen: true }{ type: 'itemRemoved', productId: 'lamp' }{ selectedIds: ['notebook', 'pen', 'mug'], isOpen: true }
{ selectedIds: ['notebook', 'pen', 'mug'], isOpen: true }{ type: 'itemRemoved', productId: 'pen' }{ selectedIds: ['notebook', 'mug'], isOpen: true }
{ selectedIds: ['notebook', 'mug'], isOpen: true }{ type: 'itemRemoved', productId: 'mug' }{ selectedIds: ['notebook'], isOpen: false }

Relate state to the tray interface

Connect snapshots to rendered output

The interface reflects the same timeline. The count is derived from selectedIds, so two selected products render 2 of 3 selected. The inline Comparison section additionally requires isOpen.

After the successful open action, two selected products show the panel. A duplicate addition, an over-capacity addition, and removal of an absent product do not change what is visible. Removing pen leaves two selections, so the panel remains open. Removing mug next leaves only one selection, and that transition closes the panel.

Visible effects of the timeline

Storyboard showing the Comparison panel opening with two selections, staying open after one removal that leaves two selections, and disappearing after a removal leaves one selection.

The panel is controlled by isOpen; the displayed count is derived from the selected IDs.

Apply the complete trace

Trace a coordinated transition

Starting from initialState, trace these actions in order:

  1. { type: 'itemAdded', productId: 'notebook' }
  2. { type: 'itemAdded', productId: 'pen' }
  3. { type: 'comparisonOpened' }
  4. { type: 'itemAdded', productId: 'lamp' }
  5. { type: 'itemAdded', productId: 'lamp' }
  6. { type: 'itemRemoved', productId: 'pen' }
  7. { type: 'itemRemoved', productId: 'lamp' }

Write the state snapshot after each action. Name the guarded action and explain why it leaves the state unchanged. Then identify the exact transition that closes the open comparison and explain why.

Step 8 of 8

Apply and Review the Complete Model

Apply the complete Comparison Tray model, verify a coordinated removal, and choose the right boundary for reducer state.

Review the coordinated flow

One coordinated feature trace

A complete Comparison Tray interaction can cross several state fields, but the interface still reads one current snapshot. The action contract describes intent, the reducer applies the rules, and the rendered controls provide feedback from the resulting state.

Review this sequence as one continuous trace: select products, explicitly open comparison, remove a product, then reset the tray. The visible panel must agree with isOpen, while the count is derived from selectedIds.

From intent to visible feedback

A flow diagram showing current state, semantic actions, a reducer rules gate, and next state for the Comparison Tray.

The interface reflects the next state produced by the reducer; it does not independently decide feature rules.

A continuous state timeline

Each row uses the previous row’s next state as its current state.

Comparison Tray transitions preserve selection order.
Current stateActionNext state
{ selectedIds: [], isOpen: false }{ type: 'itemAdded', productId: 'notebook' }{ selectedIds: ['notebook'], isOpen: false }
{ selectedIds: ['notebook'], isOpen: false }{ type: 'itemAdded', productId: 'pen' }{ selectedIds: ['notebook', 'pen'], isOpen: false }
{ selectedIds: ['notebook', 'pen'], isOpen: false }{ type: 'comparisonOpened' }{ selectedIds: ['notebook', 'pen'], isOpen: true }
{ selectedIds: ['notebook', 'pen'], isOpen: true }{ type: 'itemRemoved', productId: 'notebook' }{ selectedIds: ['pen'], isOpen: false }
{ selectedIds: ['pen'], isOpen: false }{ type: 'selectionCleared' }{ selectedIds: [], isOpen: false }

Apply the rule in one transition

Repair the coordinated transition

Complete the removal branch so that a successful removal also preserves the open-comparison rule. This branch already handles an absent product by returning the existing state.

Removal branch

Fill the missing comparison operator.

typescript
case 'itemRemoved': {
  if (!state.selectedIds.includes(action.productId)) {
    return state;
  }

  const selectedIds = state.selectedIds.filter(
    (id) => id !== action.productId
  );

  return {
    selectedIds,
    isOpen: selectedIds.length  2 ? false : state.isOpen
  };
}

Apply the complete model

Final application check

Use this starting snapshot:

{ selectedIds: ['notebook', 'pen'], isOpen: true }

The reducer receives { type: 'itemRemoved', productId: 'pen' }.

In one response:

  1. Write the missing operator in selectedIds.length [operator] 2 ? false : state.isOpen.
  2. Give the exact next state and say whether the inline Comparison section is rendered.
  3. Explain why Comparison Tray benefits from useReducer, while an unrelated simple boolean such as isHelpVisible can remain in useState.

Write at least 180 characters (0/180).

Wrap up

Summary

Reducer decision checklist

  • Define the smallest state contract and state invariants before writing transitions.
  • Use typed, semantic actions so each intention carries only the data it needs.
  • Keep reducer branches pure, return complete next state snapshots, and guard blocked actions.
  • Dispatch action objects from controls; render counts, availability, and the inline panel from reducer-owned state.
  • Trace each action from the previous result and check both state fields and visible feedback.
  • Choose a reducer for coordinated transitions and centralized rules; keep independent, straightforward local values in useState.

Tutorial complete

Congratulations! You completed: Model Client-State Transitions with Reducers

You can now model coordinated client-state transitions with a typed reducer, verify their outcomes, and choose deliberately between useReducer and local useState.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate