Lekce 7 · Hooky do hloubky
Vlastní hooky
Jak vytáhnout a znovu použít logiku: useLocalStorage, useMediaQuery, useDebounce…
Načítám lekci…
Lekce 7 · Hooky do hloubky
Jak vytáhnout a znovu použít logiku: useLocalStorage, useMediaQuery, useDebounce…
Načítám lekci…
Bešamelová omáčka je v kuchařce jen jednou, ale používá se v lasagních, v gratinovaných bramborách i v mnoha dalších jídlech. Recept na lasagne nepopisuje znovu, jak se dělá jíška a kolik se přilévá mléka. Napíše jen „připrav bešamel (str. 12)“. Recept je díky tomu kratší, čitelnější, a když se postup na bešamel zlepší, zlepší se ve všech jídlech najednou.
Vlastní hook je takový základní recept. Když se ti v komponentách opakuje stejná kombinace hooků – stav, efekt, posluchač událostí, úklid – vytáhneš ji do funkce s názvem use… a komponenta pak jen napíše useOnlineStatus().
Důležitý detail: když vaříš lasagne a brambory zároveň, nedělí se o jeden hrnec bešamelu. Každé jídlo si podle stejného receptu uvaří vlastní omáčku. Stejně je to s hooky: hook sdílí postup (logiku), ne hrnec (stav). Dvě komponenty, které volají stejný hook, mají každá svůj vlastní, nezávislý stav.
Chceme v hlavičce ukázat, jestli je uživatel online. Prohlížeč to ví (navigator.onLine) a při změně pošle událost online nebo offline. Z lekce o efektech už víš, jak to napsat:
function StatusBar() {const [isOnline, setIsOnline] = useState(navigator.onLine)useEffect(() => {const goOnline = () => setIsOnline(true)const goOffline = () => setIsOnline(false)window.addEventListener('online', goOnline)window.addEventListener('offline', goOffline)return () => {window.removeEventListener('online', goOnline)window.removeEventListener('offline', goOffline)}}, [])return <p>{isOnline ? '🟢 Online' : '🔴 Offline'}</p>}
Pak přijde požadavek: tlačítko „Uložit“ má být offline vypnuté. Tlačítko je jiná komponenta, takže bychom museli celý stav s efektem zkopírovat. Místo toho ho vytáhneme do vlastního hooku.
function useOnlineStatus() {const [isOnline, setIsOnline] = useState(navigator.onLine)useEffect(() => {// …stejný efekt jako výše…}, [])return isOnline}
use. Podle toho React (a linter) pozná, že uvnitř jsou hooky a platí pro ni pravidla hooků.useState, objekt.function StatusBar() {const isOnline = useOnlineStatus()return <p>{isOnline ? '🟢 Online' : '🔴 Offline'}</p>}function SaveButton() {const isOnline = useOnlineStatus()return <button disabled={!isOnline}>{isOnline ? 'Uložit' : 'Čekám na připojení…'}</button>}
Komponenty teď čtou jako věta: „zjisti, jestli jsem online, a podle toho se zobraz“. Jak se to zjišťuje, je schované v „receptu na str. 12“.
A pozor na hrnce: StatusBar i SaveButton mají každá svůj useState a svůj efekt se svými posluchači. Hodnoty se shodují jen proto, že obě poslouchají stejný prohlížeč – ne proto, že by sdílely stav.
Typický hook, který spojuje stav s „okolním světem“. Chová se jako useState, ale hodnotu si navíc ukládá do localStorage prohlížeče, takže po obnovení stránky (F5) nezmizí.
function useLocalStorage<T>(key: string, initialValue: T) {// 1) Líná inicializace: z úložiště čteme jen při prvním renderuconst [value, setValue] = useState<T>(() => {const stored = localStorage.getItem(key)return stored !== null ? JSON.parse(stored) : initialValue})// 2) Efekt: po každé změně hodnotu zapiš do úložištěuseEffect(() => {localStorage.setItem(key, JSON.stringify(value))}, [key, value])// 3) Vrať stejnou dvojici jako useStatereturn [value, setValue] as const}// Použití – vypadá úplně jako useState:const [note, setNote] = useLocalStorage('note', '')
useState se podívá do úložiště. Když tam něco je (z minulé návštěvy), použije to; jinak počáteční hodnotu.setNote změní stav, komponenta se překreslí a efekt hodnotu zapíše do úložiště.as const na konci říká TypeScriptu, že vracíme dvojici [hodnota, setter] s pevným pořadím, ne pole, ve kterém může být cokoli z obojího.
Co vidíš: Pole na poznámku a velikost písma – obě používají useLocalStorage. Tlačítko náhledu používá druhý malý hook useToggle, který vrací hodnotu a funkci na přepnutí.
Vyzkoušej:
useToggle si nic neukládá, je to obyčejný stav.Co z toho plyne: Komponenta používá useLocalStorage stejně jako useState. Že se hodnota ukládá, je schované v hooku.
Načítám interaktivní část…
V lekci o stavu jsme říkali, že stav je sešit, který kuchaři drží React. Teď prozradíme detail: stránky toho sešitu nemají jména, jen čísla. React si nepamatuje „stránka name“, ale „první useState v komponentě, druhý useState, třetí…“. Při každém renderu prochází hooky v pořadí, v jakém jsou zavolané, a ke každému otevře další stránku.
Co se stane, když hook zavoláš jen někdy:
function Form({ showEmail }: { showEmail: boolean }) {const [name, setName] = useState('')if (showEmail) {const [email, setEmail] = useState('') // ❌ hook v podmínce}const [age, setAge] = useState(0)}
| Pořadí | showEmail = true | showEmail = false |
|---|---|---|
| Stránka 1 | name | name |
| Stránka 2 | email | age – ale na stránce je e-mail! |
| Stránka 3 | age | (nikdo) |
Když se showEmail přepne na false, třetí hook dostane druhou stránku sešitu – tu s e-mailem. Stav se „posune“ a komponenta pracuje s cizími daty. Proto musí být pořadí hooků při každém renderu stejné.
| Pravidlo | Proč |
|---|---|
| Volej hooky jen na nejvyšší úrovni – ne v podmínkách, cyklech, po early return | React páruje stav s hooky podle pořadí volání. Jiné pořadí = jiná stránka sešitu. |
| Volej je jen z komponent a jiných hooků | Mimo render React neví, čí sešit má otevřít. |
Jméno začíná use | Linter tak pozná hook a zkontroluje pravidla i závislosti. |
Výjimka: use() | Hook use() (pro Promise a Context) nepoužívá stránku sešitu, proto smí být i v podmínce. |
Náš useOnlineStatus z kapitoly 1 funguje, ale má slabinu: hodnotu si drží v kopii (vlastním useState) a doufá, že ji efekt stihne aktualizovat. V krátkém okamžiku mezi změnou a efektem může komponenta ukázat starou hodnotu.
Lepší je chovat se jako předplatitel novin. Nedržíš si vlastní opis, ale řekneš vydavateli: „až vyjde nové číslo, dej mi vědět“ (subscribe). A kdykoli potřebuješ aktuální stav, podíváš se do schránky (getSnapshot). Přesně tohle dělá useSyncExternalStore:
// Předplatné: React ti dá funkci onChange, ty ji zaregistruješ u prohlížečefunction subscribe(onChange: () => void) {window.addEventListener('resize', onChange)return () => window.removeEventListener('resize', onChange) // zrušení předplatného}// Pohled do schránky: aktuální hodnotafunction getSnapshot() {return window.innerWidth}function useWindowWidth() {return useSyncExternalStore(subscribe, getSnapshot)}
subscribe a předá mu funkci onChange. Ty ji pověsíš na událost resize.getSnapshot a vrátí aktuální šířku. Žádná kopie ve stavu.onChange. React se podívá do schránky, a pokud je tam jiná hodnota, komponentu překreslí.subscribe vrátil.Funkce subscribe je definovaná mimo komponentu, aby byla pořád stejná. Kdyby vznikala při každém renderu znovu, React by předplatné pokaždé rušil a zakládal. Na stejném principu je postavený Zustand, Redux i navigace tohoto kurzu.
Co vidíš: Tři hooky postavené na useSyncExternalStore: šířka okna, systémové téma (přes matchMedia) a stav připojení.
Vyzkoušej:
Co z toho plyne: Každý řádek je jeden řádek kódu v komponentě. Předplatné, úklid a čtení aktuální hodnoty jsou schované v hooku.
Načítám interaktivní část…
Hook nemusí řešit jen jednu konkrétní věc. Může to být obecný recept s „přísadou“ v parametru. Typický příklad: načtení dat se stavem načítám / hotovo / chyba a ochranou proti závodu odpovědí z lekce o efektech.
function useAsync<T>(fn: () => Promise<T>, deps: unknown[]) {const [state, setState] = useState<AsyncState<T>>({ status: 'loading' })useEffect(() => {let ignore = falsesetState({ status: 'loading' })fn().then((data) => !ignore && setState({ status: 'success', data }),(error) => !ignore && setState({ status: 'error', error }),)return () => { ignore = true }}, deps)return state}// Komponenta řeší jen to, CO načíst a jak to ukázat:function UserCard({ id }: { id: number }) {const user = useAsync(() => fetchUser(id), [id])if (user.status === 'loading') return <p>Načítám…</p>if (user.status === 'error') return <p>{user.error.message}</p>return <p>{user.data.name}</p>}
Co vidíš: Dvě karty UserCard, obě používají useAsync. Levá načítá uživatele podle tlačítka, pravá má napevno uživatele #1.
Vyzkoušej:
error z hooku.Co z toho plyne: Jeden recept na načítání, každé použití s vlastní přísadou a vlastním stavem.
Načítám interaktivní část…
formatPrice), je obyčejná funkce. Pojmenuj ji bez use – jinak na ni zbytečně platí pravidla hooků a nesmí se volat v podmínce.useMount(fn) nebo useUpdate(fn). Schovávají závislosti a svádějí k efektům tam, kde nemají být. Pojmenovávej hooky podle účelu: useChatRoom, useDebounce, useOnlineStatus.Pro inspiraci – tyhle hooky se hodí skoro v každém projektu:
useDebounce(value, ms) useInterval(fn, ms)useLocalStorage(key, init) usePrevious(value)useMediaQuery(query) useOnClickOutside(ref, fn)useCopyToClipboard() useIntersectionObserver(ref)useForm(schema) useAuth() / usePermissions()
Napiš tělo jednoduchého vlastního hooku. Dvě zaškrtávátka pak budou fungovat nezávisle na sobě.
VÝZVA: Hook useToggle Komponenta už hook používá dvakrát. Chybí jen jeho tělo. 1. V useToggle si založ stav: const [on, setOn] = useState(initial) 2. Vrať pole [on, toggle], kde toggle = () => setOn((v) => !v)
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…
useDebounce je nejpoužívanější hook vůbec: vezmeš logiku z výzvy v lekci o efektech a uděláš z ní recept. Dropdown procvičí dva malé hooky s posluchači událostí, které se hodí u každého menu a modalu.
Celý hook má asi 8 řádků – a komponenta, která ho používá, bude o polovinu kratší.
VÝZVA: Hook useDebounce Generický hook useDebounce(value, delay) vrací `value`, ale až poté, co se `value` po dobu `delay` ms nezměnila. Signatura je připravená, celé UI taky. 1. V hooku drž stav `debounced` (počáteční hodnota = value). 2. V efektu se závislostmi [value, delay] nastav setTimeout, který po `delay` ms zavolá setDebounced(value). V cleanupu timeout zruš. Hook vrátí `debounced`. 3. V DebounceDemo použij hook: debouncedQuery = useDebounce(query, delay). Porovnej se lekcí o efektech: logika je stejná, ale teď je znovupoužitelná a komponenta je mnohem čitelnější.
Načítám interaktivní část…
Dva dropdowny na stránce musí fungovat nezávisle – dobrá kontrola, že každé použití hooku má opravdu vlastní hrnec.
Střední a těžké výzvy jsou bonus pro přihlášené. Registrace je zdarma – stačí e-mail, žádné heslo.
use…, která volá jiné hooky a schová opakující se logiku.products)products – ze souboru src/course/fakeApi.ts. Falešné API kurzu: data a funkce, které předstírají server (se zpožděním, občas i s chybou). Ve skutečné aplikaci by tu bylo volání fetch().export interface Product {id: numbername: stringcategory: 'ovoce' | 'zelenina' | 'pečivo' | 'mléčné'price: numberinStock: boolean}export const products: Product[] = [{ id: 1, name: 'Jablko', category: 'ovoce', price: 12, inStock: true },{ id: 2, name: 'Banán', category: 'ovoce', price: 8, inStock: true },{ id: 3, name: 'Hruška', category: 'ovoce', price: 15, inStock: false },{ id: 4, name: 'Mrkev', category: 'zelenina', price: 6, inStock: true },{ id: 5, name: 'Rajče', category: 'zelenina', price: 9, inStock: true },{ id: 6, name: 'Okurka', category: 'zelenina', price: 19, inStock: false },{ id: 7, name: 'Rohlík', category: 'pečivo', price: 3, inStock: true },{ id: 8, name: 'Chléb', category: 'pečivo', price: 45, inStock: true },{ id: 9, name: 'Mléko', category: 'mléčné', price: 22, inStock: true },{ id: 10, name: 'Sýr eidam', category: 'mléčné', price: 39, inStock: true },{ id: 11, name: 'Jogurt', category: 'mléčné', price: 14, inStock: false },{ id: 12, name: 'Pomeranč', category: 'ovoce', price: 11, inStock: true },]
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).