Lekce 5 · Hooky do hloubky
Složitější stav (useReducer)
Kdy místo useState sáhnout po reduceru a jak ho psát čistě a typově bezpečně.
Načítám lekci…
Lekce 5 · Hooky do hloubky
Kdy místo useState sáhnout po reduceru a jak ho psát čistě a typově bezpečně.
Načítám lekci…
Představ si, že by banka fungovala takhle: kdokoli z pracovníků může otevřít tvůj účet a přepsat zůstatek na číslo, které si sám spočítá. Pokladní u přepážky, aplikace v mobilu, bankomat, automat na poplatky – každý si to počítá trochu po svém. Jakmile se zůstatek jednou nesejde, nikdo nezjistí proč.
Skutečná banka to dělá jinak. Nikdo zůstatek nepřepisuje. Každý jen zapíše, co se stalo: „vklad 500 Kč“, „platba kartou 120 Kč“, „měsíční poplatek“. Tomu se říká transakce. A nový zůstatek spočítá jedno místo podle pevných pravidel: k vkladu přičti, platbu odečti, a když na účtu nic není, platbu zamítni.
Tenhle přístup má velké výhody:
useReducer přináší přesně tenhle přístup do Reactu. Komponenty nepřepisují stav, jen hlásí akce (transakce). Nový stav spočítá reducer – funkce s pravidly.
Začneme seznamem úkolů napsaným tak, jak ho umíš z lekce o stavu:
function TaskList() {const [tasks, setTasks] = useState<Task[]>([])function handleAdd(text: string) {setTasks([...tasks, { id: nextId++, text, done: false }])}function handleToggle(id: number) {setTasks(tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)))}function handleDelete(id: number) {setTasks(tasks.filter((t) => t.id !== id))}function handleClearDone() {setTasks(tasks.filter((t) => !t.done))}// …JSX, které tyhle funkce volá}
Funguje to. Ale pravidla, jak se seznam mění, jsou rozházená ve čtyřech funkcích – každá „přepisuje zůstatek“ po svém. Až jich bude dvacet a budou rozdělené do několika komponent, těžko zjistíš, kdo co změnil. Přepíšeme to na reducer ve třech krocích.
Každá funkce výše odpovídá jedné události. Popíšeme je jako akce – obyčejné objekty s vlastností type a daty, která k události patří:
type Action =| { type: 'added'; text: string }| { type: 'toggled'; id: number }| { type: 'deleted'; id: number }| { type: 'cleared_done' }
Akce jsou transakce: „úkol přidán, text: Nakoupit“. Neříkají, jak se má pole změnit – jen co se stalo.
Reducer dostane aktuální stav a akci a vrátí nový stav. Všechna logika ze čtyř handlerů se přestěhuje sem:
function tasksReducer(tasks: Task[], action: Action): Task[] {switch (action.type) {case 'added':return [...tasks, { id: nextId++, text: action.text, done: false }]case 'toggled':return tasks.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t))case 'deleted':return tasks.filter((t) => t.id !== action.id)case 'cleared_done':return tasks.filter((t) => !t.done)}}
Všimni si, že je to obyčejná funkce, nic z Reactu. Pravidla mutací z lekce o stavu platí dál – vždy vracíš nové pole.
function TaskList() {const [tasks, dispatch] = useReducer(tasksReducer, [])// místo handleToggle(id):<input type="checkbox" onChange={() => dispatch({ type: 'toggled', id: task.id })} />// místo handleAdd(text):<form onSubmit={() => dispatch({ type: 'added', text })}>}
useReducer(tasksReducer, []) je jako useState([]), jen místo setteru vrátí dispatch – „okénko, kam se podávají transakce“.dispatch(akce) nic nepočítá. Předá akci Reactu a ten zavolá reducer.dispatch({ type: "toggled", id: 2 }).tasksReducer(aktuálníÚkoly, { type: "toggled", id: 2 }).'toggled' a vrátí nové pole, ve kterém má úkol 2 přepnuté done.Co vidíš: Seznam úkolů z příkladu. Vpravo je log akcí – „výpis z účtu“. Každý dispatch se do něj zapíše dřív, než ho zpracuje reducer.
Vyzkoušej:
{"type":"added","text":"Uvařit oběd"}.cleared_done odstranila víc úkolů najednou – pravidlo „co se má smazat“ zná jen reducer.Co z toho plyne: Komponenta jen hlásí, co se stalo. Jak se změní stav, rozhoduje jedno místo – a log akcí je přehledný příběh. Přesně takhle fungují Redux DevTools.
Načítám interaktivní část…
Ne všechno potřebuje banku. Drobné ve vlastní kapse si taky nevedeš přes transakce – prostě víš, kolik tam máš. Stejně je to se stavem: jednoduché, nezávislé hodnoty jsou s useState přehlednější.
| useState (drobné v kapse) | useReducer (bankovní účet) |
|---|---|
| Jednoduché, nezávislé hodnoty (text inputu, otevřeno/zavřeno) | Víc hodnot, které se mění spolu |
| Pár způsobů, jak stav změnit | Mnoho různých akcí nad stejnými daty |
| Nový stav snadno spočítáš v handleru | Nový stav závisí na předchozím složitě (validace, přechody) |
| — | Chceš logiku testovat samostatně nebo sdílet přes Context |
Pračka má programy: připraveno → pere → ždímá → hotovo. Nemůže být zároveň „pere“ a „hotovo“. A když pere, tlačítko Start nic neudělá – není kam přejít. Takovému zařízení se říká stavový automat: má pár jasně pojmenovaných stavů a pravidla, jak se mezi nimi přechází.
Načítání dat se často píše jako sada nezávislých hodnot:
// ❌ Tři přepínače = osm kombinací, ale smysl dává jen párconst [isLoading, setIsLoading] = useState(false)const [error, setError] = useState<string | null>(null)const [user, setUser] = useState<User | null>(null)// isLoading && error && user? Pračka, která pere, je hotová a zároveň porouchaná.
Jako stavový automat to vypadá takhle. Každý stav nese jen data, která k němu patří:
type State =| { status: 'idle' } // připraveno| { status: 'loading' } // pere| { status: 'success'; user: User } // hotovo – a jen tady je user| { status: 'error'; error: string } // porucha – a jen tady je errortype Action =| { type: 'fetch' }| { type: 'resolve'; user: User }| { type: 'reject'; error: string }function reducer(state: State, action: Action): State {switch (action.type) {case 'fetch':// Pravidlo přechodu: když už se načítá, další „Start“ nic neměníreturn state.status === 'loading' ? state : { status: 'loading' }case 'resolve':return { status: 'success', user: action.user }case 'reject':return { status: 'error', error: action.error }}}
A kde je samotné načítání? Mimo reducer, v handleru. Reducer dostane jen výsledky jako akce:
async function load(id: number) {dispatch({ type: 'fetch' }) // pračka: peretry {const user = await fetchUser(id)dispatch({ type: 'resolve', user }) // pračka: hotovo} catch (e) {dispatch({ type: 'reject', error: (e as Error).message }) // pračka: porucha}}
TypeScript pak v JSX hlídá, že sahat na state.user můžeš jen tam, kde jsi ověřil state.status === 'success'. Nesmyslnou kombinaci nejde ani zapsat.
Co vidíš: Reducer z příkladu výše. Karta ukazuje obsah podle stavu, pod ní je aktuální status. Uživatelé se načítají z falešného API se zpožděním.
Vyzkoušej:
idle → loading → success a karta ukáže jméno.loading do error a karta ukáže chybu. Jméno z minula zmizelo – ve stavu error žádný user není.fetch ignoroval – jako pračka, která už pere.idle.Co z toho plyne: Stav je vždy právě v jednom ze čtyř pojmenovaných stavů a nese jen data, která k němu patří. Přechody hlídá reducer.
Načítám interaktivní část…
Banka zůstatek počítá podle pravidel a nic jiného při tom nedělá – nevolá klientům, neposílá dopisy. Kdyby to dělala, každý přepočet (třeba při kontrole) by poslal další dopis. U reduceru je to stejné: React ho může zavolat víckrát, ve vývojovém režimu to dělá schválně.
// ❌ Nečistý reducercase 'added':saveToServer(action.text) // volání APIreturn [...tasks, { id: Math.random(), ... }] // náhoda – pokaždé jiný výsledek// ✅ Čistý reducer: ze stejného stavu a akce vždy stejný výsledekcase 'added':return [...tasks, { id: action.id, text: action.text, done: false }]// ID vytvoříš v handleru a pošleš v akci; API zavoláš v handleru nebo v efektu
Math.random(), Date.now() ani mutace. Co je náhodné nebo asynchronní, proběhne venku a do reduceru přijde v akci.'item_added' je lepší než 'set_items'. Transakce „vklad 500“, ne „nastav zůstatek na 1500“.default dej const x: never = action. Když přidáš novou akci a zapomeneš ji obsloužit, TypeScript tě upozorní.Reduceru chybí jediná akce. Doplň ji a vypínač začne fungovat.
VÝZVA: Vypínač
Reducer už umí akci 'off'. Chybí akce 'toggle'.
1. V reduceru doplň case 'toggle': vrať { on: !state.on }
Tlačítka už akce posílají (dispatch).row, stack, card, list, btn, input nebo muted jsou hotové styly kurzu v src/styles.css (row = prvky vedle sebe, stack = pod sebou, card = rámeček, muted = šedý text).Načítám interaktivní část…
Průvodce procvičí kapitoly 1 a 3: všechna pravidla přechodů (validace, další/zpět) patří do reduceru, komponenta jen posílá akce. Pixel art přidá historii: když máš všechny změny jako akce, je undo/redo překvapivě jednoduché.
Krok za krokem jako pračka: z kroku 1 se nedostaneš na krok 2, dokud reducer nepotvrdí, že jsou údaje v pořádku.
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
Klasický vzor historie (past/present/future), který používají editory, tabulky i Redux-undo. Vybraná barva v historii není – to jsou drobné v kapse.
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
dispatch.useState pro drobné UI věci.