setState není přiřazení, je to zápis do fronty
Nejčastější zdroj zmatku kolem stavu v Reactu je jedno slovo. setNumber vypadá jako setter, tedy jako přiřazení. Není. Je to zápis položky do fronty, kterou React zpracuje až později. Dokud čteš setState jako přiřazení, chová se React nepředvídatelně. Jakmile ho čteš jako frontu, začne sedět všechno včetně případů, které vypadají jako chyba.
React totiž po každém volání nerenderuje. Čeká, až doběhne všechen kód v event handleru, a teprve pak zpracuje nasbírané updaty. Tomu se říká batchování a docs to ilustrují číšníkem: nesprintuje do kuchyně, jakmile řekneš první chod. Nechá tě doobjednat, nechá tě objednávku změnit a poslouchá i ostatní u stolu.
Proč tři plus jedna dělá jedna
Vezmi klasiku, na které se to láme:
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
Při number rovném nule skončíš na jedničce, ne na trojce. Důvod není v batchování samotném, ale v tom, co se do fronty zařadilo. Hodnoty stavu jsou v rámci jednoho renderu pevné. Proměnná number je v tomhle renderu prostě nula a nic ji nezmění. Všechny tři řádky se tedy vyhodnotí ještě před zařazením do fronty a React dostane:
setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);
Tři položky, každá říká „nahraď hodnotou 1". Fronta se projede shora dolů a vyhraje poslední. Jednička.
Updater funkce mění položku fronty na příkaz
Když místo hodnoty předáš funkci, zařadíš do fronty něco jiného. Ne „nahraď tímhle", ale „udělej tohle s tím, co tam v tu chvíli bude".
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
React projede frontu a výstup každé položky předá jako vstup té další: nula dá jedničku, jednička dvojku, dvojka trojku. Konec na trojce.
Zajímavější je míchání obou tvarů, protože ukazuje, že jde opravdu o jednu frontu se dvěma druhy položek:
setNumber(number + 5); // nahraď hodnotou 5
setNumber(n => n + 1); // vezmi 5, vrať 6
Skončíš na šestce. A když na konec přidáš setNumber(42), dostaneš 42 — protože náhradní hodnota zahodí všechno, co ve frontě bylo před ní. Fronta má paměť jen do nejbližší náhrady.
Podmínka: updater musí být čistý. Jen spočítá a vrátí výsledek. Žádné nastavování dalšího stavu ani side-efekty uvnitř, protože React tu funkci může zavolat i vícekrát.
Kde se batchování láme
Batchování v současném Reactu drží i mimo event handlery — v setTimeout, v promisech, v nativních listenerech. To je změna oproti starším verzím, kde se batchovalo jen uvnitř Reactových handlerů a všude jinde se renderovalo po každém setState. Kód psaný na to staré chování se nikde nerozbil, jen přestal renderovat zbytečně.
Zbývá jedna cesta ven, flushSync. Ta říká Reactu, ať updaty uvnitř callbacku propíše synchronně a DOM byl aktualizovaný hned na dalším řádku. Bez ní je stav v DOMu ještě starý.
Kanonický případ z docs je onbeforeprint: chceš stránku upravit dřív, než se otevře tiskový dialog, a bez flushSync dialog ukáže starý stav, protože React updaty zpracovává asynchronně. Docs u toho ale drží varování na plnou pusu — použití je vzácné, výkonu to škodí a může to nečekaně přinutit čekající Suspense boundaries ukázat fallback. Sáhni po tom jen při integraci s cizím kódem, který ti neumožní počkat na příští render.
Co si z toho odnést
Přeříkej si setState jako „zařaď do fronty" a většina překvapení zmizí. Hodnota v proměnné je pro celý render zamrzlá, takže setX(x + 1) třikrát za sebou počítá pořád ze stejného čísla.
Praktické pravidlo je jednoduché: kdykoli počítáš nový stav z toho starého, předej funkci, ne hodnotu. setPending(p => p + 1) je vždy bezpečné, setPending(pending + 1) jen do prvního souběhu nebo asynchronní uzávěry. A když ti někdo řekne, že se hodnota „nestihla aktualizovat", nehledej zpoždění — hledej, jestli nečteš proměnnou z renderu, který o té frontě ještě nic neví.