Kae Job Sluder

23 August 2025: React Reducer Pattern

Some of my "side" project work includes prototyping with strategies that I'd like to apply at work. We have moderately complex, legacy Vue and React interfaces that are difficult to test for a variety of reasons. One of my maintenance tasks is look for ways to refactor for testability, and some of the themes for React are:

  1. Division of application state into multiple useState() variables.
  2. Internal React variables are not directly observable without rendering. This makes it difficult to test "business" (data modification) logic separate from UI rendering logic.
  3. Multiple UI events can trigger state changes.
  4. Tacit updates and component lifecycle events trigger unexpected results and errors.

The reducer pattern helps to address many of these problems.

ttrpg-tools is a side project to create a set of creative brainstorming oracles for solo role-playing games. Many of these games include a mechanic that increases the risk of negative events as play continues. Examples include jenga towers and playing card layouts.

My experimental mechanic uses solitaire blackjack with the addition of a "resource" pile of cards that can be played at any time. The "dealer" represents a hostile environment, and players will need to balance wins and losses to reach the endgame. My first attempt at managing game state through React's useState came out something of a mess, which pushed me to read more about useReducer as an alternative.


erDiagram

		direction LR
		state ||--|| view : configures
		state {
			Card[] playerHand
			Card[] dealerHand
			Card[] resources
			Card[] discardPile
			number playerScore
			number dealerScore
		}

		view ||--|{ actions : sends
		view {
			ul playerHand
			ul dealerHand
			ul resources
			div playerScore
			div dealerScore
			nav turnNav
		}

		actions }|--|| reducer : direct
		actions {
			action setupRound
			action playerTurn
			action playerHit
			action playerStand
			action dealerTurn
			action scoreRound
		}

		reducer ||--|| state : updates
		reducer {
			param state
			param action
			returns state
		}

Currently the game code consists of four main components, each with their own responsibilities:

{type: 'SETUP_ROUND'}

The reducer is simply an extended switch statement. Here's a minimal example

/**
 * Create a reducer function using drawPile as the array iterator.
 * @param {ArrayIterator<Card>} drawPile
 * @returns {(SurvivalBlackjack, ReducerAction) => SurvivalBlackjack} reducer function
 */
export function makeReducer(
  drawPile: ArrayIterator<Card>, // shuffled deck for game
): (state: SurvivalBlackjack, action: ReducerAction) => SurvivalBlackjack {

	// The reducer starts here as a closure.
  return function reducer(state: SurvivalBlackjack, action: ReducerAction) {
    switch (action.type) {
      // start the game drawing resources
      case turnStage.start: // turnStage is a typescript enum
        return produce(state, (state) => { // immer.produce() ensures immutability
          state = state.setup(drawPile).setStage(turnStage.start);
        });
...

drawPile is an iterator object with its own internal state. I keep it separate from state to ensure that state can be immutably handled by both React and immer without complications. This is 'passed' into the reducer via a closure. The reducer and initial game state are created early and added to the view with the following line of code:

const [game, dispatch] = useReducer(reducer, initialGame);

This is parallel to [state, setState] = useState(initialState) State properties can be accessed through game.current for rendering. The view does not directly modify the state, which makes handling events very simple:

<button onClick={() => dispatch({ type: sb.turnStage.playerTurn })}>
  Start Game
</button>

dispatch calls the reducer, captures the new game state, and triggers a redraw. The reducer takes care of round and game management. The state methods take care of game mechanics. Some of the advantages include:

  1. MVC organization.
  2. Game management, game mechanics, and display logic can be tested independently.
  3. Direct access to state properties for testing.
  4. Game management and game mechanics are not dependent on any UI framework.
  5. Interdependent states can be managed together, without worrying about the rendering cycle.