Użycie pokazuje dwie rzeczy naraz. Zod sprawdza odpowiedź w runtime, a TypeScript inferuje Product[] z typu zwracanego przez parseProducts:
Code
import { z } from 'zod'const productSchema = z.object({ id: z.string(), name: z.string(), price: z.number().nonnegative(),})const productsSchema = z.array(productSchema)type Product = z.infer<typeof productSchema>function parseProducts(value: unknown): Product[] { return productsSchema.parse(value)}function ProductList() { const state = useFetch('https://api.example.com/products', parseProducts) if (state.status === 'loading') return <p>Ładowanie...</p> if (state.status === 'error') return <p>Błąd. {state.error.message}</p> return ( <ul> {state.data.map((product) => ( <li key={product.id}> {product.name}, {product.price} PLN {/* ↑ autocomplete zna pola Product */} </li> ))} </ul> )}
Funkcja parse znajduje się w tablicy zależności efektu, dlatego powinna mieć stabilną referencję. Najprościej zadeklarować ją na poziomie modułu, tak jak parseProducts. Jeśli parser zależy od propsów, utwórz go przez useCallback. Nie wolno usuwać reaktywnej wartości z tablicy tylko po to, aby wyciszyć regułę exhaustive-deps.
AbortController anuluje poprzednie żądanie po zmianie URL-a lub parsera i podczas odmontowania komponentu. Dzięki temu wolniejsza odpowiedź nie nadpisze nowszego stanu. W produkcyjnej aplikacji pobieranie danych często lepiej oddać . Cache, deduplikacja żądań i ponowienia to osobne problemy, których ten edukacyjny hook celowo nie rozwiązuje. W Next.js App Router dane potrzebne podczas renderowania strony zwykle pobieraj w Server Component. Kliencki hook zostaw dla danych zależnych od interakcji lub API przeglądarki.
Stan jako discriminated union
FetchState<T> nie jest przypadkowo zbiorem trzech opcjonalnych pól. Taki model dopuszczałby stany, które nie powinny wystąpić, między innymi data i error jednocześnie albo aktywne ładowanie z błędem. zapisuje trzy dozwolone warianty wprost:
function ProductList() { const state = useFetch('https://api.example.com/products', parseProducts) if (state.status === 'loading') return <p>Ładowanie...</p> if (state.status === 'error') return <p>Błąd. {state.error.message}</p> // TypeScript wie, że status === 'success' i data istnieje. // Nie potrzeba optional chainingu ani obsługi null. return ( <ul> {state.data.map((product) => ( <li key={product.id}>{product.name}</li> ))} </ul> )}
Generic i union współpracują. T parametryzuje tylko wariant success, a zawężanie po status daje konsumentowi dokładnie te pola, które w danym stanie istnieją. To najważniejszy wzorzec typowania stanu w tym artykule.
Generyczny hook useLocalStorage<T> w React
Code
// hooks/use-local-storage.tsimport { useEffect, useRef, useState } from 'react'import type { Dispatch, SetStateAction } from 'react'export function useLocalStorage<T>( key: string, initialValue: T,): readonly [T, Dispatch<SetStateAction<T>>, boolean] { const initialValueRef = useRef(initialValue) const [storedValue, setStoredValue] = useState<T>(initialValue) const [loadedKey, setLoadedKey] = useState<string | null>(null) useEffect(() => { let nextValue = initialValueRef.current try { const item = window.localStorage.getItem(key) if (item !== null) nextValue = JSON.parse(item) as T } catch { console.warn(`Nie udało się odczytać localStorage dla klucza ${key}`) } setStoredValue(nextValue) setLoadedKey(key) }, [key]) useEffect(() => { if (loadedKey !== key) return try { window.localStorage.setItem(key, JSON.stringify(storedValue)) } catch { console.warn(`Nie udało się zapisać localStorage dla klucza ${key}`) } }, [key, loadedKey, storedValue]) return [storedValue, setStoredValue, loadedKey === key] as const}
Generic znika w JavaScript, dlatego test zachowania zawsze korzysta z konkretnego typu. Osobno warto dodać asercję typu, która wykryje przypadkowe osłabienie publicznego API hooka:
Code
import { act, renderHook } from '@testing-library/react'import { expect, expectTypeOf, it } from 'vitest'it('dodaje element i zachowuje typ listy', () => { const { result } = renderHook(() => useList<Todo>([])) expectTypeOf(result.current.items).toEqualTypeOf<Todo[]>() act(() => { result.current.add({ id: '1', text: 'Przetestuj hook', done: false }) }) expect(result.current.items).toHaveLength(1)})
Test runtime sprawdza zachowanie, a expectTypeOf pilnuje kontraktu kompilatora. Dla bibliotek warto dołożyć przypadki negatywne z // @ts-expect-error i uruchamiać je przez tsc --noEmit.
Ten wariant celowo wymaga, aby wejście i wyjście schematu Zod miały ten sam typ T. Jeśli schema transformuje dane, rozdziel typ formularza od typu przekazywanego do onSubmit przez dwa parametry generyczne. W rozbudowanych formularzach lepszym wyborem będzie wyspecjalizowana biblioteka, ponieważ touched fields, walidacja asynchroniczna i tablice pól szybko powiększają zakres własnego hooka.
Zasady projektowania reużywalnych hooków z TypeScript generics
Zacznij od wspólnego kontraktu. Wprowadź T, gdy co najmniej dwa użycia różnią się typem danych, ale zachowują tę samą logikę.
Dodawaj constraints tylko wtedy, gdy implementacja ich potrzebuje. <T extends object> wyklucza typy proste, a <K extends keyof T> wiąże nazwę pola z typem jego wartości.
Preferuj inferencję z argumentów. Jeśli TypeScript może wydedukować T, jawne podanie typu tylko powtarza informację.
Używaj typów domyślnych dla rzeczywistego wariantu domyślnego. Zapis <T = string> ma sens, gdy API może zostać wywołane bez argumentu pozwalającego wywnioskować T.
Modeluj stany przez discriminated union. Zawężanie po polu status zastępuje żonglowanie wartościami null.
Waliduj granice systemu w runtime. Generic opisuje kontrakt na etapie kompilacji, ale dane z API, storage i formularzy nadal mogą być niepoprawne.
Przestrzegaj Rules of Hooks. Custom hook musi zaczynać nazwę od use, a hooki wewnątrz niego wywołuj wyłącznie na najwyższym poziomie funkcji.
Domyślny typ z punktu 4 wygląda w praktyce tak. useEventLog<T = string>() działa bez argumentów na stringach, a useEventLog<AnalyticsEvent>() dostaje pełne typowanie dla bogatszego payloadu. Domyślny generic nie jest potrzebny, jeśli typ zawsze wynika z argumentu.
W plikach .tsx zapis const identity = <T>(x: T) => x wygląda dla parsera jak otwarty tag JSX i może zakończyć się kaskadą błędów składni. Dodaj przecinek po parametrze (<T,>(x: T) => x) albo constraint (<T extends unknown>). Generyczne hooki bez JSX możesz też trzymać w plikach .ts.
W Next.js hooki korzystające ze stanu, efektów i API przeglądarki muszą ostatecznie znaleźć się w drzewie Client Component. Dyrektywa 'use client' wyznacza granicę tego drzewa i nie musi występować w każdym importowanym pliku z hookiem.
Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.
Czy generics spowalniają działanie aplikacji w runtime?
Nie. Generics to wyłącznie mechanizm TypeScriptu. Po kompilacji do JavaScriptu znikają bez śladu. Nie ma żadnego narzutu w czasie działania aplikacji; cała praca dzieje się na etapie sprawdzania typów w edytorze i podczas buildu. Generyczny useFetch<T> kompiluje się do dokładnie tego samego JS-a co jego niegeneryczny odpowiednik.
Kiedy NIE warto robić hooka generycznym?
Gdy hook jest realnie związany z jedną domeną, na przykład useShoppingCart z logiką rabatów i podatków. Generics dodają warstwę abstrakcji, która ma sens tylko wtedy, gdy hook faktycznie będzie używany z różnymi typami. Jeśli zawsze obsługuje jeden konkretny kształt danych, jawny typ jest prostszy do czytania i utrzymania.
Jak TypeScript wie, jaki typ ma generyczny hook bez jawnego podania `<T>`?
Przez inferencję z argumentów. Przy useLocalStorage('theme', 'light') TypeScript wnioskuje T jako string z wartości początkowej, więc nie musisz pisać <string>. Jawny generic podajesz tylko tam, gdzie nie da się go wywnioskować. W useFetch typ może wynikać z funkcji parsującej odpowiedź, ponieważ z samego URL-a nie da się go ustalić.
Jak typować stan hooka, żeby nie sprawdzać wszędzie nulli?
Zamiast trzech niezależnych pól (data, error, isLoading) użyj discriminated union: stan to { status: 'loading' } | { status: 'success', data: T } | { status: 'error', error: Error }. Po sprawdzeniu status === 'success' TypeScript zawęża typ i wie, że data istnieje. Nie potrzebujesz optional chainingu, a niemożliwe kombinacje w rodzaju „loading z błędem naraz" przestają się kompilować.
Jak testować generyczny custom hook?
Testuj go z konkretnymi typami przez renderHook, np. renderHook(() => useList<string>(['a', 'b'])). Nie testujesz „generyczności" w oderwaniu. Sprawdzasz zachowanie na realnych typach i ich przypadkach brzegowych. Testy typów uzupełniają testy runtime i pokazują, czy constraints oraz inferencja działają zgodnie z kontraktem.
Po co stosować constraint w stylu `<T extends object>`?
Constraint ogranicza, jakie typy wolno podstawić pod generic, i odblokowuje operacje zależne od kształtu T. W useForm<T extends object> zapewnia, że T nie jest typem prostym, dzięki czemu możesz typować setValue jako <K extends keyof T>. Bez constraintu kontrakt hooka dopuszczałby także wartości, które nie nadają się na model formularza.
O autorze
Maciej Sala
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.
W tym artykule pokazuję siedem wzorców, które sam stosuję produkcyjnie — w StriveLab https://strivelab.pl/tworzenie-stron-internetowych/react/ , Army Builder https://army-builder.com , i u klientów. Każdy z nich rozwiązuje konkretny problem, który w 2026 roku masz rozwiązywać lepiej niż pięć lat temu, bo TypeScript i React dostały nowe narzędzia.
Maciej Sala
Founder StriveLab
React 19 pozwala przekazać funkcję bezpośrednio do atrybutu action elementu . Dzięki integracji z Actions, useActionState , useFormStatus i useOptimistic możesz ograniczyć ręczną obsługę onSubmit , pending state oraz optymistycznych aktualizacji , zachowując kontrolę nad błędami i stanem trwałym.
Maciej Sala
Founder StriveLab
Klasyczny formularz bardzo szybko robi się ciężki przez takie elementy jak endpoint, odczytywanie ciała żądania, walidacja, statusy HTTP, fetch po stronie klienta i typy przepisane drugi raz. Na ratunek przybywają Astro Actions: wystarczy, że zdefiniujesz logikę funkcji serwerowej, dodasz schemat Zod i wywołasz ją z klienta bez ręcznego składania REST API.