Lekce 8 · Formuláře
Formuláře a React 19 Actions
Controlled vs. uncontrolled, useActionState, useFormStatus a useOptimistic.
Načítám lekci…
Lekce 8 · Formuláře
Controlled vs. uncontrolled, useActionState, useFormStatus a useOptimistic.
Načítám lekci…
Na úřadě můžeš formulář vyplnit dvěma způsoby. Buď si ho vezmeš ke stolku, v klidu vyplníš a odevzdáš na přepážce – úředník ho zkontroluje až tam. Nebo si sedneš k úředníkovi, který ti kouká přes rameno a u každého políčka hned řekne: „rodné číslo má lomítko“, „tady chybí PSČ“. Druhý způsob je pohodlnější pro tebe, ale úředníka stojí víc práce.
V Reactu jsou to dva typy formulářových polí:
Když formulář odevzdáš, nastane druhá polovina příběhu. Úředník si ho vezme, nad přepážkou se rozsvítí „zpracovává se“, a za chvíli dostaneš formulář zpátky – buď s razítkem, nebo s poznámkami, co opravit. Tohle všechno (čekání, výsledek, chyby) jsme v Reactu dřív psali ručně pořád dokola. React 19 na to má Actions – vestavěnou podatelnu.
Pole pro slevový kód ve formátu ABC-123. Chceme, aby se písmena sama převáděla na velká a aby se hned ukázalo, jestli je kód platný:
function CouponField() {const [code, setCode] = useState('')const valid = /^[A-Z]{3}-\d{3}$/.test(code)return (<><inputvalue={code}onChange={(e) => setCode(e.target.value.toUpperCase().slice(0, 7))}/><span>{valid ? '✓ Platný kód' : 'Formát: ABC-123'}</span></>)}
onChange dostane "a", převede ho na "A" a uloží do stavu.value="A". V poli je velké A – i když jsi napsal malé.valid a zpráva pod polem se aktualizuje.Protože každé písmeno prochází přes tebe, můžeš ho upravit, omezit a hned zkontrolovat. Cenou je render při každém stisku klávesy – u pár polí zanedbatelné.
Formulář s městem a velikostí trička. Nic nekontrolujeme průběžně, hodnoty potřebujeme až při odeslání:
function ShippingForm() {function handleSubmit(e: React.FormEvent<HTMLFormElement>) {e.preventDefault()const data = new FormData(e.currentTarget)console.log(data.get('city'), data.get('size'))}return (<form onSubmit={handleSubmit}><input name="city" defaultValue="Praha" /><select name="size" defaultValue="M">…</select><button>Odeslat</button></form>)}
useState. Hodnotu drží prohlížeč, stejně jako v obyčejném HTML.defaultValue je jen počáteční hodnota. React ji nastaví jednou a dál se o pole nestará.FormData podle atributu name.| Controlled | Uncontrolled | |
|---|---|---|
| Kde je hodnota | Ve stavu Reactu (value + onChange) | V prohlížeči (defaultValue, čteš přes FormData) |
| Render při psaní | Při každém znaku | Žádný |
| Hodí se pro | Živou validaci, formátování, závislá pole, vypnuté tlačítko | Jednoduché formuláře, odeslání přes action, velké formuláře |
Co vidíš: Vlevo pole na slevový kód z prvního příkladu (controlled). Vpravo formulář s městem a velikostí z druhého příkladu (uncontrolled).
Vyzkoušej:
abc-12. Písmena se hned změní na velká a pole je červené. Doplň 3 – zpráva se změní na „✓ Platný kód“.FormData.Co z toho plyne: Controlled pole můžeš řídit při každém písmenu. Uncontrolled je jednodušší a stačí tam, kde hodnotu potřebuješ až při odeslání.
Načítám interaktivní část…
Takhle se formulář s odesláním na server psal dřív. Všimni si, kolik kódu se netýká samotné registrace:
function Signup() {const [isPending, setIsPending] = useState(false)const [error, setError] = useState<string | null>(null)async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {e.preventDefault() // zabránit reloadu stránkyif (isPending) return // ochrana proti dvojklikusetIsPending(true)setError(null)try {await registerUser(Object.fromEntries(new FormData(e.currentTarget)))e.currentTarget.reset() // vyčistit formulář} catch (err) {setError((err as Error).message)} finally {setIsPending(false)}}// …}
V Reactu 19 můžeš do <form action> předat funkci. React ji zavolá s FormData, sám sleduje, jestli ještě běží, a po dokončení formulář vyčistí. K tomu dva pomocníci:
interface FormState {errors?: { name?: string; email?: string }message?: string}// Action: dostane minulý výsledek a vyplněný formulář, vrátí nový výsledekasync function signup(prev: FormState, formData: FormData): Promise<FormState> {const name = String(formData.get('name')).trim()const email = String(formData.get('email')).trim()if (name.length < 2) return { errors: { name: 'Jméno musí mít aspoň 2 znaky' } }if (!email.includes('@')) return { errors: { email: 'Neplatný e-mail' } }try {const user = await registerUser({ name, email })return { message: `Vítej, ${user.name}!` }} catch (e) {return { errors: { email: (e as Error).message } }}}function Signup() {const [state, formAction, isPending] = useActionState(signup, {})return (<form action={formAction}><input name="name" />{state.errors?.name && <p>{state.errors.name}</p>}<input name="email" />{state.errors?.email && <p>{state.errors.email}</p>}<SubmitButton />{state.message && <p>{state.message}</p>}</form>)}
Co se stane po kliknutí na „Registrovat“:
preventDefault) a zavolá signup(minulýStav, formData).isPending true – nad přepážkou svítí „zpracovává se“.state a komponenta se překreslí.useActionState je vlastně useReducer pro formuláře: action dostane předchozí stav a „událost“ (vyplněný formulář) a vrátí stav nový – jen smí být asynchronní.
import { useFormStatus } from 'react-dom'function SubmitButton() {const { pending } = useFormStatus()return <button disabled={pending}>{pending ? 'Registruji…' : 'Registrovat'}</button>}
useFormStatus čte stav nejbližšího nadřazeného <form>. Kontrolku vidí ten, kdo stojí u přepážky – proto musí být v komponentě uvnitř formuláře, ne v té, která <form> vykresluje. Odměnou je tlačítko, které můžeš vložit do jakéhokoli formuláře a samo pozná, že se odesílá.
Co vidíš: Registrační formulář z příkladu. Action validuje, volá falešné API (trvá zhruba sekundu) a vrací chyby nebo zprávu. Tlačítko je samostatná komponenta s useFormStatus. Dole je vidět hodnota isPending.
Vyzkoušej:
@obsazeno.cz. Během čekání se tlačítko přepne na „Registruji…“ a isPending na true. Pak se objeví chyba ze serveru.Co z toho plyne: Čekání, chyby, ochranu proti dvojkliku i reset řeší React. Ty píšeš jen to podstatné: co se s formulářem má stát.
Načítám interaktivní část…
Číšník si objednávku zapíše na účet hned, jak ji řekneš – nečeká, až kuchyně potvrdí, že suroviny jsou. Ve většině případů jsou. A když výjimečně dojdou, přijde a položku z účtu škrtne. Pro tebe je to mnohem příjemnější než čekat u stolu na potvrzení každé položky.
Optimistické UI funguje stejně. U akcí, které skoro vždy uspějí (lajk, přidání do košíku, odeslání zprávy), ukážeš výsledek hned a server potvrdí na pozadí. Když selže, změna se vrátí.
function LikeButton() {const [likes, setLikes] = useState(10) // potvrzeno serveremconst [optimisticLikes, addOptimistic] = useOptimistic(likes,(current, delta: number) => current + delta, // jak „odhadnout“ výsledek)function like() {startTransition(async () => {addOptimistic(1) // 1) hned ukaž +1try {await likePost(1) // 2) počkej na serversetLikes((l) => l + 1) // 3a) potvrď skutečný stav} catch {// 3b) nic nenastavuj – odhad po skončení akce sám zmizí}})}return <button onClick={like}>❤️ {optimisticLikes}</button>}
| Okamžik | likes (skutečnost) | optimisticLikes (zobrazeno) |
|---|---|---|
| Před kliknutím | 10 | 10 |
| Hned po kliknutí | 10 | 11 |
| Server potvrdil | 11 | 11 |
| …nebo server selhal | 10 | 10 – odhad zmizel |
useOptimistic drží odhad jen po dobu běžící akce (proto startTransition – ve formulářové action je to automatické). Když akce skončí, zobrazí se zase skutečný stav – potvrzený, nebo původní.
Co vidíš: Tlačítko s počtem lajků a vedle něj počet potvrzený serverem. Server odpovídá za 0,7 s a ve 25 % případů selže.
Vyzkoušej:
Co z toho plyne: Uživatel vidí výsledek okamžitě. Skutečný stav se mění jen podle odpovědi serveru a při chybě se odhad sám vrátí.
Načítám interaktivní část…
Pro formuláře s desítkami polí, dynamickými řádky a složitou validací sáhni po React Hook Form (rychlý díky uncontrolled polím) nebo TanStack Form. Validaci popiš schématem v Zod nebo Valibot – stejné schéma pak zkontroluje data i na serveru a TypeScript z něj odvodí typ:
const schema = z.object({email: z.string().email('Neplatný e-mail'),password: z.string().min(8).regex(/\d/, 'Aspoň jedna číslice'),})type FormValues = z.infer<typeof schema> // typ zdarma ze schématu
Propoj input se stavem – dva atributy.
VÝZVA: Řízené pole
Stav `name` je připravený, ale input s ním není propojený.
1. Inputu dej value={name}
2. a onChange={(e) => setName(e.target.value)}
Pozdrav pod polem se pak bude měnit při psaní.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…
První výzva je klasický controlled formulář (kapitola 1) s dobrým UX: chyby se ukážou až po opuštění pole. Druhá spojí kapitoly 2 a 3 – formulář bez jediného onSubmit a komentář, který se objeví dřív, než ho server uloží.
Úředník přes rameno, ale ohleduplný: neupozorňuje na chybu dřív, než pole opustíš.
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
Moderní formulář bez jediného onSubmit a bez ručního stavu načítání.
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
<form action={fn}> řeší čekání, dvojklik i reset.useActionState vrací výsledek akce (chyby, zprávy), useFormStatus je kontrolka pro tlačítka uvnitř formuláře.