Przejdź do treści

Tworzenie reużywalnych custom hooks z typami generics

Zobacz, jak wykorzystać typy generyczne w TypeScript do budowania elastycznych, bezpiecznych i reużywalnych custom hooków w React.

Maciej Sala

Founder StriveLab

5 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 23 lipca 2026)

Problem: custom hooki działające tylko z jednym typem danych

Zamiast 10 hooków robiących to samo możesz stworzyć jeden hook z , który obsługuje różne typy:

Code
// Zamiast:
const { data } = useFetchProducts() // data: Product[]
const { data } = useFetchUsers() // data: User[]
const { data } = useFetchOrders() // data: Order[]
 
// Jeden kontrakt hooka, różne parsery i typy wynikowe:
const products = useFetch('/api/products', parseProducts)
const users = useFetch('/api/users', parseUsers)
const orders = useFetch('/api/orders', parseOrders)

Generyczny hook useFetch<T> w TypeScript

Code
// hooks/use-fetch.ts
import { useEffect, useState } from 'react'
 
type FetchState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }
 
type Parser<T> = (value: unknown) => T
 
export function useFetch<T>(url: string, parse: Parser<T>): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: 'loading' })
 
  useEffect(() => {
    const controller = new AbortController()
 
    async function fetchData() {
      setState({ status: 'loading' })
 
      try {
        const response = await fetch(url, { signal: controller.signal })
        if (!response.ok) throw new Error(`HTTP ${response.status}`)
 
        const rawData: unknown = await response.json()
        const data = parse(rawData)
 
        if (!controller.signal.aborted) {
          setState({ status: 'success', data })
        }
      } catch (error: unknown) {
        if (controller.signal.aborted) return
 
        const normalizedError =
          error instanceof Error ? error : new Error('Nieznany błąd')
 
        setState({ status: 'error', error: normalizedError })
      }
    }
 
    void fetchData()
    return () => controller.abort()
  }, [url, parse])
 
  return state
}

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:

Code
type FetchState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

Największą korzyść widać po stronie konsumenta:

Code
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.ts
import { 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
}
Code
// Typ jest inferowany z initialValue.
const [theme, setTheme, isThemeReady] = useLocalStorage('theme', 'light')
//     ^-- string
 
const [cart, setCart] = useLocalStorage<CartItem[]>('cart', [])
//     ^-- CartItem[]
 
const [settings, setSettings] = useLocalStorage('settings', {
  notifications: true,
  language: 'pl',
})
//     ^-- { notifications: boolean; language: string }

Generyczny hook useList<T> do pracy z listami

Hook udostępnia operacje dodawania, usuwania, aktualizacji i czyszczenia tablicy:

Code
// hooks/use-list.ts
import { useState, useCallback } from 'react'
 
interface UseListReturn<T> {
  items: T[]
  add: (item: T) => void
  remove: (predicate: (item: T) => boolean) => void
  update: (predicate: (item: T) => boolean, updater: (item: T) => T) => void
  clear: () => void
  set: (items: T[]) => void
}
 
export function useList<T>(initialItems: T[] = []): UseListReturn<T> {
  const [items, setItems] = useState<T[]>(initialItems)
 
  const add = useCallback((item: T) => {
    setItems((prev) => [...prev, item])
  }, [])
 
  const remove = useCallback((predicate: (item: T) => boolean) => {
    setItems((prev) => prev.filter((item) => !predicate(item)))
  }, [])
 
  const update = useCallback(
    (predicate: (item: T) => boolean, updater: (item: T) => T) => {
      setItems((prev) =>
        prev.map((item) => (predicate(item) ? updater(item) : item)),
      )
    },
    [],
  )
 
  const clear = useCallback(() => setItems([]), [])
  const set = useCallback((newItems: T[]) => setItems(newItems), [])
 
  return { items, add, remove, update, clear, set }
}
Code
interface Todo {
  id: string
  text: string
  done: boolean
}
 
function TodoApp() {
  const { items: todos, add, remove, update } = useList<Todo>([])
 
  function addTodo(text: string) {
    add({ id: crypto.randomUUID(), text, done: false })
  }
 
  function toggleTodo(id: string) {
    update(
      (todo) => todo.id === id,
      (todo) => ({ ...todo, done: !todo.done }),
    )
  }
 
  function deleteTodo(id: string) {
    remove((todo) => todo.id === id)
  }
 
  // ...
}

Testuj zachowanie i kontrakt typu

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.

Generyczny hook useForm<T> z walidacją formularza

Code
// hooks/use-form.ts
import { useCallback, useState } from 'react'
import type { FormEvent } from 'react'
import type { z } from 'zod'
 
type FieldName<T> = Extract<keyof T, string>
 
interface UseFormReturn<T extends object> {
  values: T
  errors: Partial<Record<FieldName<T>, string>>
  setValue: <K extends FieldName<T>>(field: K, value: T[K]) => void
  validate: () => T | null
  reset: () => void
  handleSubmit: (
    onSubmit: (values: T) => void | Promise<void>,
  ) => (event: FormEvent<HTMLFormElement>) => void
}
 
export function useForm<T extends object>(
  initialValues: T,
  schema?: z.ZodType<T, T>,
): UseFormReturn<T> {
  const [values, setValues] = useState<T>(initialValues)
  const [errors, setErrors] = useState<Partial<Record<FieldName<T>, string>>>(
    {},
  )
 
  const setValue = useCallback(
    <K extends FieldName<T>>(field: K, value: T[K]) => {
      setValues((prev) => ({ ...prev, [field]: value }))
      setErrors((prev) => {
        const nextErrors = { ...prev }
        delete nextErrors[field]
        return nextErrors
      })
    },
    [],
  )
 
  const validate = useCallback(() => {
    if (!schema) {
      setErrors({})
      return values
    }
 
    const result = schema.safeParse(values)
    if (result.success) {
      setErrors({})
      return result.data
    }
 
    const fieldErrors: Partial<Record<FieldName<T>, string>> = {}
    for (const issue of result.error.issues) {
      const field = issue.path[0]
      if (typeof field !== 'string' || !(field in values)) continue
 
      const fieldName = field as FieldName<T>
      fieldErrors[fieldName] ??= issue.message
    }
 
    setErrors(fieldErrors)
    return null
  }, [values, schema])
 
  const reset = useCallback(() => {
    setValues(initialValues)
    setErrors({})
  }, [initialValues])
 
  const handleSubmit = useCallback(
    (onSubmit: (values: T) => void | Promise<void>) => {
      return async (event: FormEvent<HTMLFormElement>) => {
        event.preventDefault()
        const parsedValues = validate()
        if (parsedValues !== null) await onSubmit(parsedValues)
      }
    },
    [validate],
  )
 
  return { values, errors, setValue, validate, reset, handleSubmit }
}
Code
import { z } from 'zod'
 
const contactSchema = z.object({
  name: z.string().min(2, 'Minimum 2 znaki'),
  email: z.email({ error: 'Podaj poprawny email' }),
  message: z.string().min(10, 'Minimum 10 znaków'),
})
 
type ContactForm = z.infer<typeof contactSchema>
 
function ContactPage() {
  const { values, errors, setValue, handleSubmit } = useForm<ContactForm>(
    { name: '', email: '', message: '' },
    contactSchema,
  )
 
  return (
    <form
      onSubmit={handleSubmit(async (data) => {
        await fetch('https://api.example.com/contact', {
          method: 'POST',
          body: JSON.stringify(data),
        })
      })}
    >
      <input
        value={values.name}
        onChange={(e) => setValue('name', e.target.value)}
        // ↑ TypeScript wie, że 'name' to klucz ContactForm, a wartość to string
      />
      {errors.name && <p className="text-red-500">{errors.name}</p>}
 
      <input
        value={values.email}
        onChange={(e) => setValue('email', e.target.value)}
      />
      {errors.email && <p className="text-red-500">{errors.email}</p>}
 
      <textarea
        value={values.message}
        onChange={(e) => setValue('message', e.target.value)}
      />
      {errors.message && <p className="text-red-500">{errors.message}</p>}
 
      <button type="submit">Wyślij</button>
    </form>
  )
}

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

  1. Zacznij od wspólnego kontraktu. Wprowadź T, gdy co najmniej dwa użycia różnią się typem danych, ale zachowują tę samą logikę.
  2. 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.
  3. Preferuj inferencję z argumentów. Jeśli TypeScript może wydedukować T, jawne podanie typu tylko powtarza informację.
  4. 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.
  5. Modeluj stany przez discriminated union. Zawężanie po polu status zastępuje żonglowanie wartościami null.
  6. Waliduj granice systemu w runtime. Generic opisuje kontrakt na etapie kompilacji, ale dane z API, storage i formularzy nadal mogą być niepoprawne.
  7. 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.
React

Często zadawane pytania

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.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat React

Czytaj dalej

Zobacz więcej wpisów
TypeScript w React bez bólu: 7 wzorców do produkcyjnych komponentów

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

Maciej Sala

Founder StriveLab

React 19 Actions — formularz bez onSubmit, useOptimistic i useActionState w praktyce

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

Maciej Sala

Founder StriveLab

Jak bezpiecznie obsługiwać formularze w Astro?

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.

Maciej Sala

Maciej Sala

Founder StriveLab