Lekce 2 · Základy
Stav a události (useState)
Immutabilita, updater funkce, batching, lifting state up a odvozený stav.
Načítám lekci…
Lekce 2 · Základy
Immutabilita, updater funkce, batching, lifting state up a odvozený stav.
Načítám lekci…
Podívej se na libovolnou aplikaci, kterou běžně používáš. Číslo u ikony košíku, rozbalené menu, text rozepsaný ve vyhledávání, zaškrtnutý úkol, vybraná záložka. To všechno jsou věci, které se mění podle toho, co uživatel dělá. A aby se mohly měnit, musí si je aplikace někde pamatovat: „v košíku jsou teď tři položky“, „menu je otevřené“. Právě téhle paměti se v Reactu říká stav.
Z minulé lekce víš, že komponenta je funkce: dostane data a vrátí popis toho, co se má zobrazit. Představ si ji jako kuchaře, který vaří podle receptu. Dostane suroviny (props), uvaří jídlo (JSX) a předá ho. Má ale jednu vlastnost: po každém jídle všechno zapomene. Když ho požádáš o další porci, začne úplně od začátku a netuší, co vařil předtím.
To je u funkcí normální. Všechno, co si funkce uvnitř vytvoří (proměnné), zmizí, jakmile skončí. Komponenta tedy sama od sebe nemá jak si zapamatovat, že už se na tlačítko třikrát klikalo.
Stav je sešit, který kuchaři drží React. Při každém vaření mu ho otevře na správné stránce: „tady máš, co sis zapsal minule“. A když chce kuchař zápis změnit, neškrtá v sešitě sám. Řekne Reactu, co tam má být příště, a React ho pak nechá uvařit nové jídlo podle nového zápisu.
V obyčejném JavaScriptu bys po kliknutí sám našel element na stránce a přepsal mu text. V Reactu to funguje obráceně. Ty jen změníš stav, React znovu zavolá komponentu a ta z nových dat spočítá, jak má obrazovka vypadat teď. Co se na obrazovce opravdu změnilo, už dohledá a přepíše React.
| Obyčejný JavaScript | React |
|---|---|
| „Najdi nápis a přepiš ho na 4.“ | „Počet je teď 4.“ |
| Ty se staráš, aby obrazovka odpovídala datům. | Obrazovka se z dat vždy spočítá znovu. |
| Snadno zapomeneš aktualizovat některé místo. | Všechna místa, která počet zobrazují, se srovnají sama. |
Každé zavolání komponenty (render) vytvoří jeden snímek obrazovky podle toho, co je zrovna ve stavu. Film vzniká tak, že se snímky střídají. Mezi dvěma snímky se změní stav a React vykreslí další. Uvnitř jednoho snímku se ale nic nemění, hodnoty jsou v něm „zamrzlé“. Tohle přirovnání se ti bude hodit hlavně v kapitole o tom, proč se nová hodnota stavu neobjeví hned.
Začneme tím nejjednodušším, co si komponenta může pamatovat: jedním číslem. Chceme tlačítko, které při každém kliknutí zvýší číslo o jedna. Kdo zná JavaScript, ale ne React, napíše nejspíš tohle:
function Counter() {let count = 0function increment() {count = count + 1}return <button onClick={increment}>Počet: {count}</button>}
Na obrazovce bude pořád Počet: 0. Proměnná se přitom opravdu zvětšuje. Jsou tu dva problémy a oba plynou z toho, že komponenta je obyčejná funkce:
Counter nezavolá znovu, takže JSX se nespočítá znovu a obrazovka zůstane stejná.Counter znovu od začátku. Řádek let count = 0 se provede znovu a hodnota je zpátky na nule.Tohle je přesně kuchař bez paměti z úvodu. Potřebujeme hodnotu, kterou si někdo pamatuje mezi voláními funkce a jejíž změna způsobí nové volání. Potřebujeme sešit, tedy stav.
Co vidíš: Dvě počítadla vedle sebe. Levé je napsané s obyčejnou proměnnou let count = 0, pravé se useState. Pod každým tlačítkem je řádek, který ukazuje, jaká hodnota je opravdu v paměti. Karta, kterou React právě vykreslil, krátce žlutě blikne.
Vyzkoušej:
let count = 0.Co z toho plyne: Obyčejná proměnná má oba problémy z textu nad ukázkou: její změna nevyvolá render a další render ji vynuluje. Stav nemá ani jeden.
Načítám interaktivní část…
import { useState } from 'react'function Counter() {const [count, setCount] = useState(0)function increment() {setCount(count + 1)}return <button onClick={increment}>Počet: {count}</button>}
Projdeme si to řádek po řádku:
useState(0) řekne Reactu: „tahle komponenta potřebuje jednu stránku v sešitě; na začátku na ní bude 0“. Počáteční hodnota se použije jen při prvním renderu. Při dalších renderech React vrátí to, co má zapsané.count je aktuální hodnota pro tento render, setCount je funkce na změnu. Jména si volíš sám, konvence je [něco, setNěco].setCount(count + 1) nic nepřepisuje přímo. Řekne Reactu: „příště chci hodnotu o jedna větší, a překresli mě“.count je const, protože ho v rámci jednoho renderu nikdy neměníš. Novou hodnotu dostane až další volání funkce.Counter(). useState(0) zatím nic zapsaného nemá, zapíše tedy 0 a vrátí count = 0. Funkce vrátí JSX s textem „Počet: 0“ a React ho zapíše do DOM.increment, ten zavolá setCount(0 + 1). React si poznamená novou hodnotu 1 a naplánuje nový render. V tuhle chvíli se ještě nic nepřekreslilo a count je v tomhle handleru pořád 0.Counter(). Tentokrát useState(0) nulu ignoruje a vrátí zapsanou 1. Funkce vrátí JSX „Počet: 1“.Formulářová pole jsou zvláštní: textové pole si pamatuje, co do něj píšeš, i bez Reactu. Tu paměť má prohlížeč. Kdybychom si totéž zapisovali ještě do stavu, měli bychom dva sešity se stejnou informací a museli bychom hlídat, aby se nerozešly. React to řeší jednoduše: řekne prohlížeči „ty si nic nepamatuj, v poli bude vždy to, co je ve stavu“. Šéfem je stav, input jen ukazuje jeho obsah. Takovému poli se říká controlled (řízené).
Typický příklad: input pro jméno, pod ním pozdrav a počítadlo znaků.
function Greeting() {const [name, setName] = useState('')const trimmed = name.trim()const greeting = trimmed === '' ? 'Ahoj, neznámý!' : `Ahoj, ${trimmed}!`return (<><input value={name} onChange={(e) => setName(e.target.value)} /><p>{greeting}</p><p>{trimmed.length} / 20 znaků</p></>)}
Co se stane, když do prázdného pole napíšeš „J“:
change a React zavolá onChange. V e.target.value je text, který by v poli byl: "J".setName("J") zapíše novou hodnotu do sešitu a naplánuje render.name === "J". Spočítá se pozdrav „Ahoj, J!“ a délka 1.value="J", takže se písmeno objeví v poli. Pozdrav i počítadlo se změní ve stejném renderu.Z příkladu si odnes tři věci:
onChange vynechal, do pole by nešlo psát: stav by zůstal prázdný a React by v poli pořád vracel prázdný text.name, takže se spočítají při každém renderu. Nemůže se stát, že by pozdrav ukazoval jiné jméno než input.Co vidíš: Komponenta z příkladu výše, doplněná o tlačítko Vymazat. Ve stavu je jediná hodnota – name. Pozdrav, počet znaků, červené varování i zapnutí tlačítka se z ní jen počítají.
Vyzkoušej:
name.trim().setName(''). Vyprázdní se input, pozdrav se vrátí na „neznámý“ a tlačítko se samo vypne.Co z toho plyne: Stačí měnit jednu hodnotu ve stavu a všechna místa, která na ní závisí, se srovnají sama. Input ukazuje stav, ne naopak.
Načítám interaktivní část…
Vraťme se ke kuchaři. Představ si, že vaří pro svatbu a do sešitu si píše seznam hostů. Měl by si vedle toho psát i „počet hostů: 48“? Nemusí – kdykoli ho potřebuje, spočítá řádky v seznamu. A kdyby si ho psal, musel by po každém novém hostovi opravit dvě místa. Jednou zapomene a čísla si začnou odporovat.
Se stavem je to stejné. Do sešitu patří jen to, co se mění v čase a nedá se spočítat z ničeho jiného. Všechno ostatní si komponenta při každém renderu dopočítá. Takovým hodnotám se říká odvozené.
Typická chyba vypadá takhle – seznam úkolů, filtr a počet hotových:
// ❌ Tři zápisy v sešitě, i když stačí dvaconst [todos, setTodos] = useState<Todo[]>([])const [filter, setFilter] = useState<'all' | 'done'>('all')const [visibleTodos, setVisibleTodos] = useState<Todo[]>([])// …a kód, který ten třetí zápis neustále doháníuseEffect(() => {setVisibleTodos(filter === 'all' ? todos : todos.filter((t) => t.done))}, [todos, filter])
Funguje to, ale špatně: po každé změně se komponenta vykreslí dvakrát (jednou se starým seznamem, podruhé s dopočítaným) a přibylo místo, kde se může něco rozejít. Správně:
// ✅ Ve stavu jen to, co nejde spočítatconst [todos, setTodos] = useState<Todo[]>([])const [filter, setFilter] = useState<'all' | 'done'>('all')// Všechno ostatní se spočítá při renderu – vždy aktuálníconst visibleTodos = filter === 'all' ? todos : todos.filter((t) => t.done)const doneCount = todos.filter((t) => t.done).length
Když si nejsi jistý, polož si o dané hodnotě tyto otázky:
| Otázka | Když ano… |
|---|---|
| Přichází to od rodiče přes props? | Není to stav (je to prop). |
| Zůstává to po celou dobu stejné? | Není to stav (je to konstanta). |
| Jde to spočítat z jiného stavu nebo props? | Není to stav – spočítej to při renderu. |
| Mění se to a UI na to musí reagovat? | Je to stav. |
Vzpomeň si na film. Každý render je jeden snímek a v něm je všechno zamrzlé – včetně hodnot stavu. Když v handleru zavoláš setCount, neměníš snímek, který se právě promítá. Zadáváš objednávku na další snímek. Proto po setCount(count + 1) uvidíš v count pořád starou hodnotu – jsi pořád ve starém snímku.
A ještě jedna věc: React nechodí „do kuchyně“ s každou změnou zvlášť. Je jako číšník, který si zapíše celou objednávku od stolu a do kuchyně jde jednou. Všechny setState z jednoho handleru si poznamená a vykreslí jeden nový snímek, až handler doběhne. Tomu se říká batching (dávkování).
function addThree() {setCount(count + 1)setCount(count + 1)setCount(count + 1)}
Čekali bychom +3, ale výsledek je +1. Všechny tři řádky čtou stejný snímek. Když je v něm count rovno 0, React dostane tři stejné objednávky:
| Volání | Co se dosadí | Co si React poznamená |
|---|---|---|
setCount(count + 1) | setCount(0 + 1) | „nastav na 1“ |
setCount(count + 1) | setCount(0 + 1) | „nastav na 1“ |
setCount(count + 1) | setCount(0 + 1) | „nastav na 1“ |
| Další snímek | count = 1 |
Řešením je napsat na lísteček místo „nastav na 1“ pokyn „přičti jedna k tomu, co tam bude“. V Reactu je to updater funkce: místo hodnoty předáš funkci, která dostane předchozí hodnotu a vrátí novou. React pokyny vyřizuje postupně a každému předá výsledek toho předchozího:
function addThree() {setCount((c) => c + 1) // 0 → 1setCount((c) => c + 1) // 1 → 2setCount((c) => c + 1) // 2 → 3}
Co vidíš: Dvě hodnoty ve stavu, a a b. Červené tlačítko volá třikrát setA(a + 1), modré třikrát setB(prev => prev + 1). Tlačítko +10 a alert(b) zavolá setB(b + 10) a hned potom vypíše b.
Vyzkoušej:
a vzroste jen o 1 – všechna tři volání četla stejný snímek, kde a bylo 0.b vzroste o 3 – každý updater dostal výsledek toho předchozího.b, i když setB už proběhl. Po zavření okna se na stránce objeví hodnota o 10 vyšší.Co z toho plyne: setState nemění proměnnou v běžícím kódu, jen objedná další snímek. Když navazuješ na předchozí hodnotu, předej updater funkci.
Načítám interaktivní část…
Zamrzlý snímek se projeví ještě víc u kódu, který se spustí později – v setTimeout, po načtení dat, v intervalu. Funkce si s sebou nese hodnoty ze snímku, ve kterém vznikla, i když se mezitím promítly další.
function addLater() {// count je „vyfocený“ v okamžiku kliknutísetTimeout(() => setCount(count + 1), 2000)}
Klikneš pětkrát rychle za sebou. Všech pět kliknutí se stalo ve snímku, kde count bylo 0, takže za dvě sekundy přijde pětkrát „nastav na 1“. Výsledek: 1. S updaterem setCount(c => c + 1) přijde pětkrát „přičti jedna“ a výsledek je 5. Zkus si to:
Co vidíš: Dvě tlačítka, která přičtou jedničku až za dvě sekundy. Červené použije hodnotu ze snímku (setWrong(wrong + 1)), modré updater (setRight(r => r + 1)).
Vyzkoušej:
Co z toho plyne: Funkce, která se spustí později, si nese hodnoty ze snímku, ve kterém vznikla. Updater si aktuální hodnotu vyzvedne až ve chvíli, kdy se opravdu provede.
Načítám interaktivní část…
Zatím jsme měli v sešitě číslo a text. Jakmile tam zapíšeš objekt nebo pole, přibude jedno pravidlo. Abys mu rozuměl, musíš vědět, jak React pozná, že se něco změnilo.
React nečte celý obsah sešitu a neporovnává ho řádek po řádku – u velkých dat by to bylo pomalé. Dívá se jen na to, jestli dostal nový list papíru. Když na starém listu něco vygumuješ a přepíšeš a podáš mu ho znovu, React se podívá, řekne „tenhle list už mám“ a nic nepřekreslí. Odborně: porovnává reference (Object.is), ne obsah.
const [todos, setTodos] = useState(['Nakoupit', 'Uklidit'])function addTodo() {// ❌ Přepisování na starém listutodos.push('Vyvenčit psa')setTodos(todos)}
push změnil obsah existujícího pole, ale je to pořád to samé pole. React porovná starou a novou hodnotu, zjistí, že jde o tentýž objekt, a render přeskočí. Úkol v poli je, na obrazovce ne.
Správně je vzít nový list, opsat na něj staré položky a připsat novou:
function addTodo() {// ✅ Nové pole → nová reference → React ví, že má překreslitsetTodos((prev) => [...prev, 'Vyvenčit psa'])}
Zápis [...prev, x] znamená „nové pole: všechny prvky z prev a za ně x“. Původní pole zůstane netknuté.
U objektů platí totéž. Neměníš user.name = 'Eva', ale vytvoříš nový objekt { ...user, name: 'Eva' } – „všechno z user, jen name bude jiné“. Když je měněná hodnota zanořená hlouběji, musíš udělat novou kopii každé úrovně na cestě k ní:
const [person, setPerson] = useState({name: 'Jana',address: { city: 'Praha', street: 'Dlouhá 1' },})// Stěhování do Brna:setPerson((p) => ({...p, // nový objekt osoby (jméno se opíše)address: {...p.address, // nový objekt adresy (ulice se opíše)city: 'Brno', // a jen tahle hodnota je jiná},}))
V ukázce si všimni zrádné věci: mutace se „neztratí“. Když klikneš na Mutovat, nic se nestane. Jakmile ale něco jiného vyvolá render, zmutovaná data se najednou objeví. Taková chyba se v reálné aplikaci hledá velmi těžko, protože se projeví jinde a jindy, než vznikla.
Co vidíš: Objekt osoby ve stavu, vypsaný jako JSON. Červené tlačítko Mutovat udělá person.tags.push(…) a předá Reactu stejný objekt. Ostatní tlačítka vytvoří nové kopie: přidají tag, odeberou první tag, nebo změní město ve vnořeném objektu address.
Vyzkoušej:
tags už dvě nové položky jsou.Co z toho plyne: Mutace se neztratí, jen se neukáže, dokud render nevyvolá něco jiného. Proto Reactu vždy předávej nový objekt nebo pole.
Načítám interaktivní část…
Tahák na nejčastější operace. Všechny vrací novou hodnotu a původní nechají být:
setItems((prev) => [...prev, newItem]) // přidatsetItems((prev) => prev.filter((i) => i.id !== id)) // odebratsetItems((prev) => prev.map((i) => (i.id === id ? { ...i, done: true } : i))) // upravit jednusetItems((prev) => prev.toSorted((a, b) => a.price - b.price)) // seřadit (ne .sort()!)setUser((prev) => ({ ...prev, address: { ...prev.address, city } })) // vnořený objekt
V restauraci nevaří jen jeden kuchař. Představ si dva: jeden přijímá objednávky, druhý připravuje účet. Kdyby si každý vedl vlastní sešit, účet by nikdy neseděl s tím, co se objednalo. Řešení je jednoduché: sešit dostane šéfkuchař, který je nad oběma. Kuchař s objednávkami mu jen hlásí „stůl 4 chce polévku“ a kuchař s účtem se dívá do šéfova sešitu.
V Reactu je šéfkuchař rodičovská komponenta. Data mezi sourozenci nejde poslat přímo – tečou jen shora dolů přes props. Když tedy stejná data potřebují dvě komponenty, přesuneš stav do jejich nejbližšího společného rodiče. Tomu se říká lifting state up (zvednutí stavu).
// ❌ Každá komponenta má vlastní sešit – o sobě nevědífunction Catalog() {const [cart, setCart] = useState<string[]>([])return <button onClick={() => setCart([...cart, 'boty'])}>Do košíku</button>}function Cart() {const [cart, setCart] = useState<string[]>([]) // pořád prázdný!return <p>V košíku: {cart.length}</p>}
Dva useState jsou dva nezávislé sešity. Katalog zapisuje do svého, košík se dívá do svého, který zůstane prázdný. Opravená verze:
// ✅ Sešit má rodič – jediný zdroj pravdyfunction Shop() {const [cart, setCart] = useState<string[]>([])const add = (item: string) => setCart((prev) => [...prev, item])return (<><Catalog onAdd={add} /> {/* dostane jen funkci na změnu */}<Cart items={cart} /> {/* dostane jen hodnotu */}</>)}function Catalog({ onAdd }: { onAdd: (item: string) => void }) {return <button onClick={() => onAdd('boty')}>Do košíku</button>}function Cart({ items }: { items: string[] }) {return <p>V košíku: {items.length}</p>}
Co se stane po kliknutí na „Do košíku“:
Catalog zavolá onAdd('boty'). To je funkce, kterou mu poslal rodič – katalog tím jen „hlásí šéfkuchaři“, co se stalo.add v rodiči zavolá setCart. Změní se stav rodiče.Cart dostane nové items a ukáže „V košíku: 1“.V ukázce je stejný princip u převodníku měn. Dvě pole, ale ve stavu rodiče je jen jedna částka a měna, ve které byla zadaná. Druhou hodnotu rodič dopočítá – kapitola 3 v praxi.
Co vidíš: Dvě pole, v korunách a v eurech (kurz 25 Kč za euro). Obě jsou stejná komponenta CurrencyInput, která sama žádný stav nemá. Rodič si pamatuje jen dvě věci: zadanou částku a měnu, ve které ji uživatel zadal. Druhé pole vždy dopočítá.
Vyzkoušej:
Co z toho plyne: Obě pole se nikdy nerozejdou, protože čtou z jednoho místa. Kdyby měl každý CurrencyInput vlastní stav, musel bys je mezi sebou ručně synchronizovat.
Načítám interaktivní část…
Dobrý sešit má jednu vlastnost: nemůže si odporovat. Každá informace je v něm jednou a jen v jedné podobě. Následující čtyři pravidla jsou vlastně jen různé podoby téhle myšlenky.
Formulář se odesílá na server. Často se to píše takhle:
// ❌ Dva přepínače = čtyři kombinace, ale smysl dávají jen třiconst [isSending, setIsSending] = useState(false)const [isSent, setIsSent] = useState(false)// isSending && isSent — „odesílá se a zároveň je odesláno“? Nesmysl, ale jde zapsat.
Stačí jednou zapomenout jeden z přepínačů vrátit a UI ukazuje spinner i potvrzení zároveň. Řešení: jedna hodnota, která může nabývat jen platných stavů.
// ✅ Nemožný stav nejde zapsatconst [status, setStatus] = useState<'idle' | 'sending' | 'sent'>('idle')// a odvozené hodnoty si klidně spočítejconst isSending = status === 'sending'
V seznamu chceš ukázat detail vybrané položky. Lákavé je uložit si do stavu celý vybraný objekt. Jenže pak má sešit tu samou položku dvakrát: v seznamu a ve výběru. Když položku v seznamu přejmenuješ, kopie ve výběru o tom neví.
// ❌ Kopie: po přejmenování ukazuje detail staré jménoconst [selected, setSelected] = useState<Fruit | null>(null)// ✅ Jen ID, objekt se dohledá při renderu – vždy aktuálníconst [selectedId, setSelectedId] = useState<number | null>(null)const selected = fruits.find((f) => f.id === selectedId) ?? null
Co vidíš: Seznam ovoce a pod ním dva detaily vybrané položky. Červený si při výběru uloží kopii objektu, zelený jen jeho ID a objekt pokaždé dohledá v seznamu. Tlačítko Přejmenovat přidá k názvu hvězdičku.
Vyzkoušej:
Co z toho plyne: Kopie je druhý zápis téže informace a dřív nebo později se rozejde s originálem. ID ukazuje na jediný zdroj pravdy.
Načítám interaktivní část…
Pokud dvě hodnoty nastavuješ vždy současně (souřadnice x a y kurzoru), je přehlednější mít je v jednom stavu { x, y }. Naopak hodnoty, které se mění nezávisle (jméno a e-mail ve formuláři), můžou mít každá svůj useState.
// ❌ Barva se z props převezme jen při prvním renderu – pozdější změny se ignorujífunction Badge({ color }: { color: string }) {const [currentColor, setCurrentColor] = useState(color)// …}// ✅ Prostě použij prop přímofunction Badge({ color }: { color: string }) {return <span style={{ color }}>…</span>}
Připomeň si: počáteční hodnota useState(x) se použije jen při prvním renderu. Pokud prop opravdu chceš jen jako výchozí hodnotu, kterou si komponenta dál upravuje sama, pojmenuj ji tak, aby to bylo vidět: initialColor.
Stav je hotový – stačí ho z tlačítek změnit.
VÝZVA: Počítadlo Stav `count` je připravený. Tlačítka zatím nic nedělají. 1. Tlačítko „+1“ ať při kliknutí zavolá setCount(count + 1). 2. Tlačítko „Vynulovat“ ať zavolá setCount(0).
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…
Teď si to vyzkoušej sám. První výzva procvičí kapitoly 3 a 5 (odvozené hodnoty a práce s polem bez mutací), druhá kapitolu 6 (sdílený stav v rodiči). Než začneš psát, rozmysli si u každé výzvy: co bude ve stavu, kde ten stav bude žít a co jen spočítám.
Klasika, na které si procvičíš všechny tři neměnné operace: přidání, úpravu a mazání. Postupuj po bodech v komentáři souboru a po každém bodu si ověř, že se obrazovka chová správně.
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
Katalog a košík jsou sourozenci – přesně situace „dva kuchaři, jeden sešit“. Stav musí žít v rodiči. Všimni si i doporučení ukládat do košíku jen productId a množství, ne kopie produktů (kapitola 7).
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
useState hodnotu uchová a jeho setter vyvolá nový render.setState → nový render → React promítne rozdíly do DOM.onChange.setX(prev => …).map, filter, toSorted).