Przejdź do treści

TypeScript w React bez bólu: 7 wzorców do produkcyjnych komponentów

Poznaj 7 praktycznych wzorców TypeScript w React: ComponentProps, discriminated unions, generics, satisfies, ref w React 19 i bezpieczne eventy.

Maciej Sala

Founder StriveLab

6 min czytaniaOpublikowano 24 kwietnia 2026 (Aktualizacja 26 czerwca 2026)

jest świetny, dopóki nie trafisz na pierwszy komponent z forwardRef, generic props, polymorphic as prop albo komunikat „type instantiation is excessively deep". W tym momencie większość deweloperów kopiuje rozwiązania ze Stack Overflow z 2020 roku — często przestarzałe i niezgodne z React 19. Poniżej siedem wzorców, które sam stosuję produkcyjnie i które rozwiązują te sytuacje raz a dobrze.

Kiedy używać konkretnych wzorców TypeScript w React?

Problem w komponencieNajlepszy wzorzecCo zyskujesz
Opakowujesz natywny element HTMLComponentProps<'button'>pełne propsy HTML, eventy i a11y bez przepisywania
Komponent ma kilka trybów działaniadiscriminated unionbrak nielegalnych kombinacji propsów
Select albo hook działa na wielu typachgenericsinferencję typu danych bez duplikacji kodu
Konfiguracja ma pasować do schematusatisfies / as const satisfieswalidację kształtu i wąskie typy tam, gdzie są potrzebne
Chcesz obsłużyć refref jako prop w React 19 albo forwardRef w React 18mniej boilerplate'u i poprawne typy referencji
Dane przychodzą z API, CMS-a albo storageunknown + type guard / Zodbezpieczeństwo także w runtime, nie tylko w kompilacji

Wzorzec 1: ComponentProps zamiast ręcznego definiowania propsów

Ręczne wypisywanie propów HTML elementu to najczęstszy błąd początkujących — i najbardziej kosztowny w utrzymaniu:

Code
// ŹLE — ręczne wypisywanie
type ButtonProps = {
  onClick?: (e: React.MouseEvent) => void
  disabled?: boolean
  type?: 'button' | 'submit' | 'reset'
  className?: string
  children?: React.ReactNode
  // ... 50 więcej propów, których nie pamiętasz
}

Zapomniałeś aria-label, tabIndex, autoFocus, form. Za każdym razem. TypeScript ma natywny helper, który to rozwiązuje:

Code
// DOBRZE
import { ComponentProps } from 'react'
 
type ButtonProps = ComponentProps<'button'> & {
  variant?: 'primary' | 'secondary'
}
 
function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
  return (
    <button className={`btn btn-${variant} ${className ?? ''}`} {...rest} />
  )
}
 
// Użycie — wszystkie natywne atrybuty button'a działają
;<Button
  variant="primary"
  onClick={handleClick}
  aria-label="Save"
  disabled={loading}
  type="submit"
/>

ComponentProps<'button'> daje Ci wszystko, co HTML'owy button ma (plus refy, eventy React). Rozszerzasz o swoje customy przez & { ... }.

Dla komponentów zewnętrznych:

Code
import { ComponentProps } from 'react'
import { Link } from 'react-router'
 
type NavLinkProps = ComponentProps<typeof Link> & {
  icon?: React.ReactNode
}

ComponentProps<typeof X> wyciąga propsy dowolnego komponentu.

Wzorzec 2: discriminated unions dla wariantów komponentu

Gdy komponent ma różne „tryby" — każdy z innymi propsami — klasyczne podejście z optional propsami szybko staje się pułapką:

Code
// ŹLE
type AlertProps = {
  type: 'info' | 'error' | 'success'
  message: string
  errorCode?: string // tylko dla error
  action?: () => void // tylko dla success
}
 
function Alert({ type, message, errorCode, action }: AlertProps) {
  // TypeScript nie wie, że errorCode i action są mutually exclusive
}

User może przekazać type="info" z errorCode — TypeScript się nie poskarży, choć to bez sensu.

Rozwiązanie — discriminated union:

Code
// DOBRZE
type AlertProps =
  | { type: 'info'; message: string }
  | { type: 'error'; message: string; errorCode: string }
  | { type: 'success'; message: string; action?: () => void };
 
function Alert(props: AlertProps) {
  if (props.type === 'error') {
    // TypeScript wie, że errorCode istnieje
    console.log(props.errorCode);
  }
  if (props.type === 'success') {
    // TypeScript wie, że action może istnieć, ale errorCode nie
    props.action?.();
  }
  return <div className={`alert alert-${props.type}`}>{props.message}</div>;
}
 
// Użycie
<Alert type="error" message="Something went wrong" errorCode="ERR_500" />
<Alert type="info" message="New feature available" />
// <Alert type="info" errorCode="..." /> — ERROR: errorCode is not valid for type='info'

TypeScript wymusza, że jeśli type to error, musisz podać errorCode. Jeśli info, errorCode jest niedozwolony. Błędy są łapane w compile time.

Stosuję to regularnie dla Button z loading state:

Code
type ButtonProps =
  | ({ loading?: false } & ComponentProps<'button'>)
  | ({ loading: true; loadingText: string } & ComponentProps<'button'>)

Jeśli loading={true}, musisz podać loadingText. Wymuszone przez typy.

Wzorzec 3: generic components w React i TypeScript

Gdy komponent powinien działać z danymi różnych typów — Select, List, Autocomplete — generics dają pełne bezpieczeństwo typów bez powielania kodu:

Code
// DOBRZE
type SelectProps<T> = {
  items: T[]
  getLabel: (item: T) => string
  getValue: (item: T) => string
  value: T | null
  onChange: (value: T) => void
}
 
function Select<T>({
  items,
  getLabel,
  getValue,
  value,
  onChange,
}: SelectProps<T>) {
  return (
    <select
      value={value ? getValue(value) : ''}
      onChange={(e) => {
        const selected = items.find((item) => getValue(item) === e.target.value)
        if (selected) onChange(selected)
      }}
    >
      {items.map((item) => (
        <option key={getValue(item)} value={getValue(item)}>
          {getLabel(item)}
        </option>
      ))}
    </select>
  )
}
 
// Użycie — generyczny User
type User = { id: string; name: string; email: string }
;<Select<User>
  items={users}
  getLabel={(user) => user.name}
  getValue={(user) => user.id}
  value={selectedUser}
  onChange={setSelectedUser}
/>

TypeScript infer'uje T automatycznie na podstawie items, więc zazwyczaj nie musisz pisać <User> explicitnie:

Code
<Select
  items={users} // T inferred as User
  getLabel={(user) => user.name} // user: User
  getValue={(user) => user.id} // user: User
  value={selectedUser}
  onChange={setSelectedUser}
/>

Generics są nieocenione dla custom hooków, które zwracają typowane dane:

Code
'use client'
 
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue
 
    const stored = window.localStorage.getItem(key)
    if (!stored) return initialValue
 
    try {
      return JSON.parse(stored) as T
    } catch {
      return initialValue
    }
  })
 
  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])
 
  return [value, setValue] as const
}
 
// Użycie
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light')

To nadal uproszczony przykład dla wartości, którym ufasz. Jeśli odczytujesz z localStorage dane biznesowe, potraktuj je jak unknown i zwaliduj schematem runtime, np. . TypeScript nie sprawdzi treści JSON-a, który użytkownik albo inny skrypt zapisał w przeglądarce.

Wzorzec 4: z propem as

Popularny wzorzec — komponent, który może renderować się jako różny element HTML albo różny komponent. Przykład: <Button as="a" href="..."> vs <Button as="button">.

Polymorphic types są notorycznie trudne w TypeScript. Najbardziej robocze rozwiązanie w 2026:

Code
import { ElementType, ComponentPropsWithoutRef } from 'react';
 
type ButtonBaseProps = {
  variant?: 'primary' | 'secondary';
  children?: React.ReactNode;
};
 
type ButtonProps<E extends ElementType> = ButtonBaseProps & {
  as?: E;
} & Omit<ComponentPropsWithoutRef<E>, keyof ButtonBaseProps | 'as'>;
 
function Button<E extends ElementType = 'button'>({
  as,
  variant = 'primary',
  children,
  ...rest
}: ButtonProps<E>) {
  const Component = as || 'button';
  return (
    <Component className={`btn btn-${variant}`} {...rest}>
      {children}
    </Component>
  );
}
 
// Użycie
<Button onClick={handleClick}>Click me</Button>  // renderuje <button>
<Button as="a" href="/home/">Home</Button>  // renderuje <a>
<Button as={Link} to="/home">Home</Button>  // renderuje <Link>

TypeScript infer'uje propsy na podstawie as: dla as="a" dopuści href, a dla as={Link} dopuści propsy komponentu Link. Nie oznacza to jednak, że wymusi wszystkie reguły semantyczne HTML. W typach Reacta href na <a> jest opcjonalny, więc jeśli w Twoim design systemie link bez href ma być błędem, musisz dopisać osobny wariant typu albo test dostępności.

Ograniczenie: nie działa idealnie z forwardRef. Dla tego — patrz Wzorzec 6.

Wzorzec 5: satisfies dla const objects

TypeScript 4.9+ dodał operator satisfies, który jest game-changerem dla config objects:

Code
// Same as const zawęża wartości, ale nie sprawdza schematu tras.
const ROUTES = {
  home: '/',
  about: '/about',
  blog: '/blog',
} as const
 
// Możesz użyć wartości jako stringa.
const path: string = ROUTES.home // OK
 
// TypeScript widzi też literalne wartości.
type Path = (typeof ROUTES)[keyof typeof ROUTES]
// Path = '/' | '/about' | '/blog'

Samo satisfies Record<string, string> sprawdzi, że wartości są stringami, ale w takim układzie TypeScript może poszerzyć wartości do string. Jeśli chcesz jednocześnie sprawdzić format i zachować literalne wartości, połącz oba mechanizmy:

Code
const ROUTES = {
  home: '/',
  about: '/about',
  blog: '/blog',
} as const satisfies Record<string, `/${string}`>
 
type Path = (typeof ROUTES)[keyof typeof ROUTES]
// Path = '/' | '/about' | '/blog'
 
const broken = {
  home: 'home',
  blog: '/blog',
} as const satisfies Record<string, `/${string}`>
// ERROR: 'home' nie pasuje do formatu `/${string}`

Różnica jest praktyczna: as const mówi „zawęź jak najmocniej", a satisfies mówi „sprawdź zgodność z typem docelowym, bez przepisywania typu zmiennej na ten typ". Gdy potrzebujesz i literalnych wartości, i walidacji kształtu, użyj as const satisfies.

Dla typed color palettes, route maps, enum'ów — nieocenione:

Code
const COLORS = {
  primary: '#0066ff',
  secondary: '#ff6600',
  danger: '#ff0000',
} satisfies Record<string, `#${string}`>
 
type ColorName = keyof typeof COLORS // 'primary' | 'secondary' | 'danger'
 
function Button({ color }: { color: ColorName }) {
  return <button style={{ background: COLORS[color] }}>Click</button>
}

Wzorzec 6: typowanie forwardRef bez bólu

Klasyczny problem — forwardRef nieprzyjemnie łączy się z generics i komponentami polymorphic. W React 19 nowy kod może częściej traktować ref jak zwykły prop:

Code
// React 19+ — ref jako regular prop
type InputProps = ComponentProps<'input'> & {
  label: string
  ref?: React.Ref<HTMLInputElement>
}
 
function Input({ label, ref, ...rest }: InputProps) {
  return (
    <label>
      <span>{label}</span>
      <input ref={ref} {...rest} />
    </label>
  )
}
 
// Użycie
const inputRef = useRef<HTMLInputElement>(null)
;<Input label="Email" ref={inputRef} type="email" />

Bez forwardRef kod jest prostszy, ale to dotyczy projektów faktycznie opartych o React 19 i aktualne typy. Jeśli nadal używasz forwardRef (React 18), składnia wygląda tak:

Code
// React 18
import { forwardRef, ComponentProps } from 'react'
 
type InputProps = ComponentProps<'input'> & { label: string }
 
const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, ...rest }, ref) => {
    return (
      <label>
        <span>{label}</span>
        <input ref={ref} {...rest} />
      </label>
    )
  },
)

Jeśli masz React 18 w projekcie, migracja na React 19 i stopniowe usuwanie niepotrzebnego forwardRef może poprawić czytelność kodu. Jeśli jednak publikujesz bibliotekę komponentów, która ma wspierać React 18 i 19 jednocześnie, zostaw forwardRef w publicznym do czasu, aż świadomie porzucisz React 18.

Wzorzec 7: type-safe event handlers i eventy DOM

Najczęstszy problem — e.target.value. TypeScript często myśli, że e.target jest EventTarget (bez .value). Rozwiązanie:

Code
// ŹLE — TypeScript krzyczy
function Input() {
  const handleChange = (e: Event) => {
    setValue(e.target.value) // Error: Property 'value' does not exist on 'EventTarget'
  }
}
 
// DOBRZE — typowane eventy Reactowe
import { ChangeEvent } from 'react'
 
function Input() {
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value) // OK, e.target jest HTMLInputElement
  }
 
  return <input onChange={handleChange} />
}

Dla inline'ów TypeScript zwykle infer'uje z onChange={...}:

Code
<input onChange={(e) => setValue(e.target.value)} /> // e typed correctly

Typowe event typy:

  • ChangeEvent<T> — dla onChange na inputach, select'ach, textarea.
  • MouseEvent<T> — dla onClick, onMouseOver.
  • KeyboardEvent<T> — dla onKeyDown, onKeyUp.
  • FocusEvent<T> — dla onFocus, onBlur.
  • FormEvent<T> — dla onSubmit.

Gdzie T to element HTML (HTMLInputElement, HTMLButtonElement, etc.).

Dla custom event handlerów, które chcesz przekazać jako prop:

Code
type InputProps = {
  value: string
  onChange: (value: string) => void // nie event, tylko value
}
 
function Input({ value, onChange }: InputProps) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />
}

Rozdzielenie „DOM event" od „business event" — komponent konwertuje eventy DOM na wygodniejsze API dla konsumenta.

Typy React i TypeScript, które warto znać

TypeScript ma gotowe helpery dla typowych sytuacji w React — wiele osób pisze je od zera, nie wiedząc, że już istnieją:

Code
// Props dla children
type Props = {
  children: React.ReactNode // wszystko, co się da wyrenderować
}
 
// Props dla style
type Props = {
  style?: React.CSSProperties // typowany style object
}
 
// Handler dla event'u z komponentu
type Props = {
  onClick: React.MouseEventHandler<HTMLButtonElement>
  onSubmit: React.FormEventHandler<HTMLFormElement>
}
 
// Ref do konkretnego HTML element'u
const divRef = useRef<HTMLDivElement>(null)
 
// Setter dla useState
type SetValueAction<T> = React.Dispatch<React.SetStateAction<T>>
 
// Context z properti'ami
type ThemeContextValue = {
  theme: string
  setTheme: (theme: string) => void
}
 
const ThemeContext = createContext<ThemeContextValue | null>(null)
 
function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used inside ThemeProvider')
  }
  return context
}

Anti-patterns TypeScript w React, których warto unikać

Cztery nawyki, które najczęściej anulują wartość TypeScriptu w projekcie.

any wszędzie — tracisz całą wartość TypeScript. Jeśli nie znasz jeszcze kształtu danych, użyj unknown (musi być zawężony przed użyciem) albo wprowadź właściwy typ zamiast odkładać na później.

React.FC jako automatyczny odruch — nie jest błędem, ale w aktualnym React + TypeScript zwykle nie jest potrzebne. Zwykła funkcja z typowanymi propsami jest czytelniejsza, lepiej współpracuje z generics i zmusza Cię do jawnego podjęcia decyzji, czy komponent przyjmuje children.

Casting as — kiedykolwiek piszesz x as SomeType, zatrzymaj się i zastanów. W 90% przypadków lepszym rozwiązaniem jest type guard lub węższy typ na początku, nie kasowanie błędu rzutowaniem.

Zbyt szerokie typystring zamiast 'small' | 'medium' | 'large', Record<string, unknown> zamiast konkretnego obiektu. Każde zawężenie to potencjalny błąd złapany w compile time, zanim dotrze do użytkownika.

Brak walidacji runtime — TypeScript kończy pracę w kompilatorze. Dane z API, CMS-a, formularza, URL-a i localStorage nadal mogą mieć zły kształt, dlatego na granicach systemu potrzebujesz type guardów albo schematów runtime.

Połączenie intuicyjności z wydajnością, które zapewnia bezproblemową skalowalność kodu.
React

Często zadawane pytania

Czy muszę używać TypeScript z React?

Nie, ale praktycznie każdy produkcyjny projekt w 2026 używa TS. Dla solo-dev'ów JavaScript może wystarczyć, dla zespołów TS jest standardem. Koszt wejścia mały, zysk w compile-time safety ogromny.

`React.FC` czy zwykła funkcja?

Zwykła funkcja jako domyślny styl. React.FC jest poprawne, ale w aktualnych typach Reacta zwykle nie daje przewagi nad jawnym typowaniem propsów, a przy komponentach generycznych bywa mniej wygodne. children dodawaj jawnie przez children?: React.ReactNode albo PropsWithChildren.

Dlaczego `as const` i `satisfies` nie są tym samym?

as const zawęża wartość do literałów i robi obiekt readonly. satisfies sprawdza zgodność z typem docelowym, ale nie zawsze samo zawęża stringi do literałów. Dla map tras często używasz obu naraz: as const satisfies Record<string, \/${string}\>.

Jak najlepiej typować custom hooki?

Zwracaj tuple (return [value, setValue] as const) dla hooków podobnych do useState lub obiekt (return { data, isLoading, error }) dla hooków podobnych do useQuery. as const jest kluczowe dla tuple'i, żeby TypeScript infer'ował typy pozycyjnie.

Czy TypeScript zastępuje PropTypes?

W typowaniu propsów aplikacji zwykle tak, ale tylko w compile time. TypeScript nie waliduje danych w runtime, więc dla API, CMS-a, formularzy i localStorage nadal potrzebujesz walidacji runtime, np. Zod.

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
Jak debugować niepotrzebne renderowanie w React?

Walka z nadmiarowymi renderami to stały punkt pracy z Reactem. O ile podwójne renderowanie w Strict Mode czy standardowa aktualizacja stanu rodzica to zachowania całkowicie naturalne, o tyle lawinowe przebudowywanie drzewa komponentów po jednym kliknięciu potrafi skutecznie zepsuć UX. React Profiler pomoże rozwiązać te problemy dostarczając twarde dane o wydajności Twojej aplikacji.

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

INP w React. 5 wzorców, które niszczą Interaction to Next Paint i jak je naprawić kodem

INP ostatecznie odesłał FID na emeryturę, wymuszając zupełnie nowe spojrzenie na responsywność. Metryka ocenia teraz najgorszą interakcję z całej sesji, bez taryfy ulgowej dla aplikacji React wypełnionych bogatym stanem, potężnymi listami i rozbudowanymi zdarzeniami. Jest jednak kilka powtarzalnych wzorców, które radzą sobie z większością problemów z optymalizacją, z których każdy posiada gotowe rozwiązanie systemowe.

Maciej Sala

Maciej Sala

Founder StriveLab