Lekce 2.3

useReducer a reducer pattern

Za každým komplexním nástrojem stojí jednoduchý princip. Stejně jako je věta složená ze slov a dům z cihel, je i useReducer postavený na základním funkcionálním vzoru: reduceru.

Co se naučíte
  • Co je reducer pattern v JavaScriptu a jak souvisí s Array.prototype.reduce
  • Jak se liší práce se stavem v čase v Reactu oproti práci s poli
  • Jak navrhnout reducer funkci a akce pro složitější stav
  • Jak oddělit logiku (reducer) od pohledu (komponenta) pomocí useReducer
  • Kdy použít useReducer místo useState v reálných aplikacích

1. Základ v JavaScriptu

Než začneme psát React s useReducer, musíme pochopit mechanismus v JavaScriptu, na kterém je postavený: reducer pattern, známý z Array.prototype.reduce.

Představte si jednoduchý e‑shop košík. Máte pole položek, z nichž každá má svou cenu:

javascript
const cart = [
  { item: "Book", price: 10 },
  { item: "Pen", price: 2 },
  { item: "Notebook", price: 5 },
]

Triviální způsob, jak spočítat celkovou částku, je použít smyčku, která mění externí proměnnou:

javascript
let total = 0 // External state

cart.forEach(product => {
  total += product.price // Mutation
})

console.log(total) // 17

To sice funguje, ale je to nečisté: funkce, která zpracovává každou položku, čte i mění total – proměnnou mimo svůj scope. Ve větších aplikacích takové roztroušené mutace ztěžují pochopení, kde a jak se stav mění.

Reducer pattern to řeší tak, že stav i transformační logika zůstanou uvnitř čisté funkce:

javascript
const cart = [
  { item: "Book", price: 10 },
  { item: "Pen", price: 2 },
  { item: "Notebook", price: 5 },
]

// The reducer function:
// accepts current state (accumulatedTotal) and the current item (product)
function cartReducer(accumulatedTotal, product) {
  return accumulatedTotal + product.price
}

const initialTotal = 0
const total = cart.reduce(cartReducer, initialTotal)

console.log(total) // 17

Funkce cartReducer je čistá: nečte ani nemění nic mimo sebe. Dostane aktuální stav (akumulátor), aktuální hodnotu (product) a vrátí nový stav. To je celý reducer pattern v jediné větě:

nextState = reducer(currentState, value)

2. Posun v Reactu: stav v čase

V JavaScriptu reduce prochází pole hodnot, které existují současně. V Reactu useReducer pracuje se sekvencí akcí, které přicházejí v čase.

Místo pole produktů si představte proud uživatelských interakcí: kliknutí, stisky kláves, přepínače, odeslání formuláře. Každá interakce je modelovaná jako akce. Reducer vezme aktuální stav UI a poslední akci a vrátí nový stav:

nextState = reducer(currentState, action)

React hook useReducer zrcadlí Array.prototype.reduce, ale místo iterace přes pole reaguje na postupně přicházející akce po celou dobu života komponenty:

tsx
const [state, dispatch] = React.useReducer(reducer, initialState)

// state:       current snapshot of your data
// dispatch:    function you call to send an action
// reducer:     pure function deciding how state changes
// initialState: starting value for the state machine

3. Reálný příklad: RPG postava

Odpoutejme se od jednoduchých čítačů. Představme si herní HUD pro postavu, jejíž stav zahrnuje životy (HP), manu (MP) a příznak, zda je naživu.

Budeme řešit tři druhy akcí: zranění, léčení a seslání kouzla (které spotřebovává manu). Reducer se stane jediným zdrojem pravdy o tom, jak tyto akce mění stav.

tsx
const initialState = {
  hp: 100,
  mp: 50,
  isAlive: true,
}

type GameAction =
  | { type: "TAKE_DAMAGE"; amount: number }
  | { type: "HEAL" }
  | { type: "CAST_SPELL" }

function gameReducer(state: typeof initialState, action: GameAction) {
  // If the character is dead, most actions stop having any effect.
  if (!state.isAlive && action.type !== "HEAL") {
    return state
  }

  switch (action.type) {
    case "TAKE_DAMAGE": {
      const newHp = state.hp - action.amount
      return {
        ...state,
        hp: newHp > 0 ? newHp : 0,
        isAlive: newHp > 0,
      }
    }

    case "HEAL": {
      return {
        ...state,
        hp: Math.min(state.hp + 20, 100), // Cap HP at 100
        isAlive: true,
      }
    }

    case "CAST_SPELL": {
      if (state.mp < 10) return state // Not enough mana
      return {
        ...state,
        mp: state.mp - 10,
      }
    }

    default: {
      return state
    }
  }
}

Všimněte si, že reducer je zcela soběstačný: dostane stav a akci a vrátí nový stav. Neví nic o tlačítkách, kliknutích ani o DOMu.

Komponenta pak pouze překládá uživatelské interakce na akce. Sama neprovádí žádné počítání se stavem – jen „hlásí, co se stalo“:

tsx
import React from "react"

export default function CharacterProfile() {
  const [state, dispatch] = React.useReducer(gameReducer, initialState)

  return (
    <div className="card space-y-4">
      <h2>
        Hero Status:{" "}
        <span className={state.isAlive ? "text-emerald-500" : "text-red-500"}>
          {state.isAlive ? "Alive" : "Fallen"}
        </span>
      </h2>
      <p>
        HP: {state.hp} | MP: {state.mp}
      </p>

      <div className="flex gap-2">
        <button
          onClick={() => dispatch({ type: "TAKE_DAMAGE", amount: 15 })}
        >
          Attack Enemy (take 15 damage)
        </button>
        <button onClick={() => dispatch({ type: "CAST_SPELL" })}>
          Cast Fireball (cost 10 MP)
        </button>
        <button onClick={() => dispatch({ type: "HEAL" })}>
          Drink Potion (+20 HP)
        </button>
      </div>
    </div>
  )
}

Toto čisté oddělení – komponenty dispatchují akce, reducer rozhoduje o vývoji stavu – škáluje mnohem lépe než rozházené volání setState a ad‑hoc logika napříč stromem.

4. Kdy použít useReducer vs useState

Scénář A: Složitý, provázaný stav

Klasický příklad je načítání dat: často sledujete loading, error a data. Tyto tři kusy stavu jsou úzce provázané – rozhodně byste neměli být „loading“ a zároveň zobrazovat chybu.

tsx
// With separate useState calls, you manually juggle invariants:
setLoading(false)
setError(null)
setData(result)

S reducerem místo toho popíšete, co se stalo, a samotný reducer zajistí, že kombinace příznaků zůstane konzistentní:

tsx
type FetchState<T> = {
  loading: boolean
  error: string | null
  data: T | null
}

type FetchAction<T> =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; payload: T }
  | { type: "FETCH_ERROR"; error: string }

function fetchReducer<T>(
  state: FetchState<T>,
  action: FetchAction<T>,
): FetchState<T> {
  switch (action.type) {
    case "FETCH_START":
      return { loading: true, error: null, data: null }
    case "FETCH_SUCCESS":
      return { loading: false, error: null, data: action.payload }
    case "FETCH_ERROR":
      return { loading: false, error: action.error, data: null }
    default:
      return state
  }
}

// Somewhere in a component:
// dispatch({ type: "FETCH_SUCCESS", payload: result })

Scénář B: Únik z dependency pasti useEffectu

Představte si semafor, který každé dvě sekundy cykluje Zelená → Oranžová → Červená → Zelená. Naivní implementace s useState často skončí efektem, který s každým renderem znovu vytváří vlastní interval:

tsx
// ❌ Problematic: effect depends on currentLight, so it tears
function TrafficLightWithState() {
  const [currentLight, setCurrentLight] = React.useState<"green" | "yellow" | "red">("green")

  React.useEffect(() => {
    const id = window.setInterval(() => {
      setCurrentLight((prev) =>
        prev === "green" ? "yellow" : prev === "yellow" ? "red" : "green",
      )
    }, 2000)

    return () => window.clearInterval(id)
  }, [currentLight]) // Effect restarts every time the light changes

  return <div className={`light ${currentLight}`} />
}

Protože je currentLight v dependency poli, efekt při každém tiknutí interval zruší a vytvoří nový. To může vést k jemným chybám v časování i k plýtvání výkonem.

S useReducer je dispatch stabilní (nemění se), takže efekt může běžet jen jednou a jednoduše dispatchovat obecnou akci „NEXT_LIGHT“:

tsx
type Light = "green" | "yellow" | "red"

type LightAction = { type: "NEXT_LIGHT" }

function lightReducer(state: Light, _action: LightAction): Light {
  if (state === "green") return "yellow"
  if (state === "yellow") return "red"
  return "green"
}

function TrafficLight() {
  const [light, dispatch] = React.useReducer(lightReducer, "green")

  React.useEffect(() => {
    const id = window.setInterval(() => {
      // We don't care what the current light is here,
      // we just say: move to the next state.
      dispatch({ type: "NEXT_LIGHT" })
    }, 2000)

    return () => window.clearInterval(id)
  }, []) // ✅ Stable: effect runs only once

  return <div className={`light ${light}`} />
}

Tento vzor – přechody stavu patří do reduceru, efekty a handlery pouze dispatchují akce – vede k předvídatelnému chování a jednodušším dependency polím.

Shrnutí

useReducer není jen „chytřejší useState“, ale způsob, jak zorganizovat změny stavu kolem čisté reducer funkce, zatímco komponenty pouze popisují, co se stalo.

Staví na stejném reducer patternu, který znáte z Array.prototype.reduce, a poskytuje přirozený nástroj pro modelování složitého, provázaného stavu bez křehké sítě setState volání.

Kdykoli máte stav s více poli, která se mění společně, připomíná stavový automat nebo trpí na problémy s dependency poli v useEffect, stojí za otázku: nebylo by to přehlednější jako reducer?