Przejdź do treści

Obsługa formularzy przez React 19 Actions i useActionState

Przestań pisać powtarzalny kod do obsługi ładowania i walidacji w formularzach. Dowiedz się jak nowe hooki w React 19 upraszczają pracę z danymi i podnoszą UX.

Maciej Sala

Founder StriveLab

8 min czytaniaOpublikowano 24 kwietnia 2026 (Aktualizacja 24 czerwca 2026)

Czym są Actions w React 19?

Action to funkcja wykonująca zmianę danych, często asynchroniczna. Funkcję przekazaną do action elementu <form> React uruchamia w Transition z obiektem FormData; po jej sukcesie resetuje niekontrolowane pola. Stan pending jest dostępny przez hooki, ale to Twój kod decyduje, czy np. wyłączyć przycisk submit.

Kliencka funkcja formularza nie jest tym samym co Server Function. Server Functions (często nazywane Server Actions w dokumentacji Next.js) wykonują kod na serwerze i wymagają wsparcia frameworka oraz dyrektywy 'use server'; klientskie Actions mogą wywołać zwykłe .

Najprostszy formularz z React 19 Actions

Code
function ContactForm() {
  async function sendMessage(formData: FormData) {
    const email = formData.get('email') as string
    const message = formData.get('message') as string
 
    const response = await fetch('https://api.example.com/contact', {
      method: 'POST',
      body: JSON.stringify({ email, message }),
    })
 
    if (!response.ok) {
      throw new Error('Nie udało się wysłać formularza.')
    }
  }
 
  return (
    <form action={sendMessage}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Wyślij</button>
    </form>
  )
}

Nie ma tu useState, preventDefault ani onSubmit. React wywoła akcję i po jej powodzeniu wyczyści niekontrolowane pola. Ten minimalny przykład nie wyłącza jednak przycisku w trakcie żądania; do tego służą isPending z useActionState albo pending z useFormStatus.

useActionState: jeden hook zamiast trzech stanów

W praktyce samo wpisanie akcji w form action nie wystarczy. Trzeba obsłużyć błędy i sukces. Do tego służy useActionState.

Code
import { useActionState } from 'react'
 
type FormState = {
  success?: boolean
  error?: string
}
 
async function contactAction(
  prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const email = formData.get('email') as string
  const message = formData.get('message') as string
 
  if (!email || !message) {
    return { error: 'Uzupełnij wszystkie pola.' }
  }
 
  try {
    const response = await fetch('https://api.example.com/contact', {
      method: 'POST',
      body: JSON.stringify({ email, message }),
    })
 
    if (!response.ok) {
      return { error: 'Nie udało się wysłać wiadomości. Spróbuj ponownie.' }
    }
 
    return { success: true }
  } catch {
    return { error: 'Nie udało się wysłać wiadomości. Spróbuj ponownie.' }
  }
}
 
function ContactForm() {
  const [state, formAction, isPending] = useActionState(contactAction, {})
 
  if (state.success) {
    return <p>Wiadomość wysłana. Odezwę się wkrótce.</p>
  }
 
  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      {state.error && <p className="error">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Wysyłanie...' : 'Wyślij'}
      </button>
    </form>
  )
}

Hook zwraca trzy rzeczy:

  1. state, czyli ostatni wynik zwrócony przez akcję (przy pierwszym renderze to wartość inicjalna, tu {}),
  2. formAction, czyli opakowany handler gotowy do wpisania w <form action={...}>,
  3. isPending, czyli flaga true/false informująca, czy akcja jest w toku.

Dla porównania, ten sam formularz przed React 19 wymagał trzech osobnych wywołań useState (loading, error, success), ręcznego e.preventDefault() i obsługi błędów.

useFormStatus i stan pending w komponentach dzieci

Jeśli przycisk submit to osobny komponent, nie ma bezpośredniego dostępu do isPending z useActionState. useFormStatus rozwiązuje ten problem, bo odczytuje stan formularza-rodzica bez przekazywania propsów.

Code
import { useFormStatus } from 'react-dom'
 
function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus()
 
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Wysyłanie...' : children}
    </button>
  )
}
 
function ContactForm() {
  return (
    <form action={sendMessage}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <SubmitButton>Wyślij wiadomość</SubmitButton>
    </form>
  )
}

Hook useFormStatus działa tylko wewnątrz komponentów renderowanych jako dzieci <form> i automatycznie szuka najbliższego formularza-przodka w drzewie React.

Poza pending zwraca też data (wysyłany FormData), method i action (referencja do funkcji akcji). Gdy do action lub formAction przekazujesz funkcję, React wysyła formularz metodą POST, niezależnie od atrybutu method.

useOptimistic i Optimistic UI bez czekania na serwer

pokazuje zmianę w interfejsie w chwili kliknięcia, jeszcze zanim serwer odpowie. Optymistyczny stan istnieje w trakcie akcji; po jej zakończeniu widok wynika ze stanu bazowego przekazanego do useOptimistic.

Klasyczny przykład to lista komentarzy:

Code
import { useOptimistic, useState } from 'react'
 
type Comment = { id: string; text: string; pending?: boolean }
 
async function addComment(formData: FormData): Promise<Comment> {
  const text = formData.get('text') as string
  const response = await fetch('https://api.example.com/comments', {
    method: 'POST',
    body: JSON.stringify({ text }),
  })
 
  if (!response.ok) {
    throw new Error('Nie udało się dodać komentarza.')
  }
 
  return response.json()
}
 
function Comments({ initialComments }: { initialComments: Comment[] }) {
  const [comments, setComments] = useState(initialComments)
  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (currentComments, newComment: Comment) => [...currentComments, newComment],
  )
 
  async function handleAction(formData: FormData) {
    const text = formData.get('text') as string
    addOptimistic({ id: crypto.randomUUID(), text, pending: true })
 
    const savedComment = await addComment(formData)
    setComments((current) => [...current, savedComment])
  }
 
  return (
    <>
      <ul>
        {optimisticComments.map((comment) => (
          <li key={comment.id}>
            {comment.text} {comment.pending && '(wysyłanie...)'}
          </li>
        ))}
      </ul>
      <form action={handleAction}>
        <input name="text" required />
        <button type="submit">Dodaj komentarz</button>
      </form>
    </>
  )
}

Użytkownik klika "Dodaj komentarz" → addOptimistic natychmiast dopisuje komentarz oczekujący → w tle idzie żądanie do API. Po sukcesie setComments aktualizuje potwierdzony stan bazowy danymi z serwera. Z kolei, jeśli akcja rzuci błąd i stan bazowy się nie zmieni, to po zakończeniu akcji tymczasowy wpis znika; na produkcji warto dodać do tego komunikat błędu lub Error Boundary.

Takie rozwiązanie zmniejsza odczuwalny czas oczekiwania, ponieważ użytkownik natychmiast widzi wpis oczekujący na potwierdzenie.

Warto rozwiać częste nieporozumienie: useOptimistic nie ma osobnego mechanizmu „rollbacku przy błędzie". Optymistyczny widok znika, bo po zakończeniu akcji React renderuje stan bazowy, a ten zmieniasz (setComments, setState) tylko po sukcesie. Gdy akcja zawiedzie i stanu bazowego nie ruszysz, interfejs po prostu wraca do tego, co pokazywał wcześniej. Nie ma więc co liczyć na automatyczne „cofnięcie". Sam decydujesz, kiedy zatwierdzić zmianę, a komunikat o błędzie dorzucasz osobno (toast albo Error Boundary).

Optimistic UI z Server Actions i revalidatePath

W Next.js z App Routerem ten sam wzorzec łączy się z : zamiast klienckiego fetch akcja oznaczona 'use server' mutuje dane i woła revalidatePath, a odświeżony stan z serwera podmienia element tymczasowy:

Code
// app/todos/actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
 
export async function addTodo(formData: FormData) {
  const text = formData.get('text') as string
  await db.todo.create({ data: { text, completed: false } })
  revalidatePath('/todos') // odśwież dane → optimistic ustępuje stanowi z bazy
}

Po stronie komponentu wołasz addOptimistic(text) natychmiast, a potem await addTodo(formData). Gdy revalidatePath przyniesie świeże dane, tymczasowy wpis z id: 'temp-...' zostaje zastąpiony rekordem z bazy. Szerzej o samej warstwie serwerowej piszę w artykule o Route Handlers czy Server Actions, a o jej testowaniu w testowaniu Server Actions.

Kiedy nie używać Optimistic UI?

Optimistic UI sprawdza się tam, gdzie operacja jest odwracalna, a błąd mało prawdopodobny: lajki, komentarze, dodawanie i usuwanie elementów listy, toggle. Trzymaj go natomiast z dala od:

  • płatności i transakcji finansowych, gdzie użytkownik musi zobaczyć realne potwierdzenie serwera, nie optymistyczną obietnicę;
  • operacji nieodwracalnych, takich jak usunięcie konta albo wysłanie zamówienia;
  • danych wymagających walidacji serwerowej, takich jak unikalne slugi i sprawdzanie dostępności;
  • operacji długo przetwarzanych, takich jak generowanie raportu albo przetwarzanie pliku.

W tych przypadkach pokaż realny isPending z loading state zamiast udawać, że już się udało.

useTransition dla akcji poza formularzem

Nie każda akcja jest wyzwalana przez <form>. Przycisk "Lubię to" czy usuwanie elementu z listy to typowe przykłady akcji bez formularza. Tu sięgasz po useTransition.

Code
import { useState, useTransition } from 'react'
 
function LikeButton({
  postId,
  initialLikes,
}: {
  postId: string
  initialLikes: number
}) {
  const [likes, setLikes] = useState(initialLikes)
  const [isPending, startTransition] = useTransition()
 
  const handleLike = () => {
    startTransition(async () => {
      setLikes((prev) => prev + 1)
      try {
        const response = await fetch(`/api/posts/${postId}/like`, {
          method: 'POST',
        })
        if (!response.ok) {
          throw new Error('Nie udało się zapisać polubienia.')
        }
      } catch {
        setLikes((prev) => prev - 1)
      }
    })
  }
 
  return (
    <button onClick={handleLike} disabled={isPending}>
      ❤️ {likes}
    </button>
  )
}

W React 19 asynchroniczna funkcja uruchamiana w startTransition jest określana jako Action. useTransition zapewnia tu isPending, ale nie dodaje semantyki formularza: nie przekazuje FormData, nie resetuje pól i nie zapewnia Progressive Enhancement.

Walidacja formularza z Zod i useActionState

Zod dobrze współpracuje z useActionState, bo pozwala zwrócić błędy pól w stanie formularza. Poniższy przykład waliduje dane przed wysłaniem do API; ten sam schemat należy zastosować również w endpointcie lub Server Function, bo walidacja klienta nie chroni serwera.

Code
import { z } from 'zod'
 
const ContactSchema = z.object({
  email: z.string().email('Podaj prawidłowy adres e-mail.'),
  message: z.string().min(10, 'Wiadomość musi mieć co najmniej 10 znaków.'),
})
 
type ContactFormState = {
  errors?: {
    email?: string[]
    message?: string[]
    _form?: string[]
  }
  success?: boolean
}
 
async function contactAction(
  _prevState: ContactFormState,
  formData: FormData,
): Promise<ContactFormState> {
  const parsed = ContactSchema.safeParse({
    email: formData.get('email'),
    message: formData.get('message'),
  })
 
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors }
  }
 
  try {
    const response = await fetch('https://api.example.com/contact', {
      method: 'POST',
      body: JSON.stringify(parsed.data),
    })
 
    if (!response.ok) {
      return {
        errors: { _form: ['Błąd serwera. Spróbuj ponownie za chwilę.'] },
      }
    }
 
    return { success: true }
  } catch {
    return { errors: { _form: ['Błąd serwera. Spróbuj ponownie za chwilę.'] } }
  }
}
 
function ContactForm() {
  const [state, formAction, isPending] = useActionState(contactAction, {})
 
  if (state.success) {
    return <p>Wiadomość wysłana.</p>
  }
 
  return (
    <form action={formAction}>
      <div>
        <label htmlFor="email">E-mail</label>
        <input id="email" name="email" type="email" />
        {state.errors?.email?.map((err) => (
          <p key={err} className="error">
            {err}
          </p>
        ))}
      </div>
 
      <div>
        <label htmlFor="message">Wiadomość</label>
        <textarea id="message" name="message" />
        {state.errors?.message?.map((err) => (
          <p key={err} className="error">
            {err}
          </p>
        ))}
      </div>
 
      {state.errors?._form?.map((err) => (
        <p key={err} className="error">
          {err}
        </p>
      ))}
 
      <button type="submit" disabled={isPending}>
        {isPending ? 'Wysyłanie...' : 'Wyślij'}
      </button>
    </form>
  )
}

z.safeParse nie rzuca wyjątków. Zwraca obiekt z polem success i, w przypadku błędu, error. Błędy walidacji lądują w stanie formularza, a nie w Error Boundary. W Next.js walidację umieść w Server Function; przy klasycznym API powtórz ją w endpointcie.

Więcej o łączeniu walidacji z React Hook Form i Zodem opisuję w osobnym artykule o React Hook Form i Zod w Next.js.

Progressive Enhancement w formularzach React 19

dla funkcji przekazywanych do action działa z Server Functions oznaczonymi 'use server'. W takim przypadku framework umożliwia przesłanie danych formularza do serwera bez potrzeby załadowania JavaScriptu. Formularz może działać przed hydracją albo przy wyłączonym JS, o ile przeglądarka ma połączenie z serwerem.

Kliencka funkcja w action w aplikacji (np. Vite) nie wykona się bez JavaScriptu. Jeśli zależy Ci na PE z React Server Functions, potrzebujesz Next.js z App Routerem lub innego frameworka wspierającego tę integrację.

Potencjalne problemy React 19 Actions

Kilka rzeczy, które regularnie powodują problemy przy pierwszym kontakcie z Actions:

  • useActionState dostaje poprzedni stan jako pierwszy argument. Sygnatura funkcji akcji to (prevState, formData), nie (formData). Pominięcie prevState skutkuje błędem typowania albo nieprawidłowym zachowaniem przy kolejnych wywołaniach.

  • useOptimistic działa tylko wewnątrz akcji lub startTransition. Próba wywołania addOptimistic poza tymi kontekstami rzuca ostrzeżenie w trybie dev.

  • Nie rzucaj wyjątków z akcji do walidacji. Błąd zamiast throw new Error(...) powinien wychodzić jako zwrócony obiekt { error: '...' }. Wyjątki trafiają do Error Boundary i rozbijają drzewo komponentów.

  • Upload plików to osobny problem. Actions nie mają API do śledzenia postępu przesyłania. Do uploaderów z procentowym paskiem postępu użyj np. XMLHttpRequest albo biblioteki oferującej upload progress.

Kiedy nie używać React 19 Actions?

Actions działają świetnie przy operacjach zorientowanych na serwer. Są niewłaściwym wyborem w trzech sytuacjach:

  • Duże formularze wieloetapowe z dziesiątkami pól, walidacją na żywo i logiką warunkową między sekcjami. Tu React Hook Form z Zodem daje precyzyjniejszą kontrolę nad stanem po stronie klienta.

  • Podgląd na żywo (np. edytor Markdown z preview obok), gdzie każda zmiana w polu nie powinna wyzwalać akcji serwera. Klasyczny useState z debouncingiem jest tu właściwym narzędziem.

  • Przesyłanie plików z paskiem postępu, ponieważ brakuje natywnego API do odczytu procentowego postępu przesyłania.

Migracja z onSubmit do React 19 Actions

Jeśli masz istniejący projekt z formularzami opartymi na onSubmit + useState, warto zacząć migrację od najprostszych przypadków:

  1. Nowe formularze od razu pisz z Actions.
  2. Formularze z React Hook Form zostaw bez zmian, chyba że chcesz uprościć konkretny komponent.
  3. Proste formularze z onSubmit + useState to najlepsi kandydaci do migracji, bo zwykle pozwalają usunąć ręczną obsługę stanu pending i submitu.

useActionState nie jest zamiennikiem dla każdego useState. To narzędzie do konkretnego wzorca: asynchroniczna akcja z wynikiem, stanem pending i obsługą błędów.

praktyka React 19
Audyt techniczny i optymalizacja pod kątem SEO i GEO.
Audyt techniczny SEO

Często zadawane pytania

Czym są React 19 Actions?

Actions to funkcje przekazywane do atrybutu action elementu <form> albo uruchamiane w Transition. React udostępnia dla nich stan pending, a po udanej akcji formularza resetuje niekontrolowane pola. Przycisk submit wyłączasz jawnie, np. przez useActionState lub useFormStatus.

Czy Actions wymagają Next.js?

Nie. Actions to natywna funkcja React 19, działająca w każdym środowisku, Vite, Remix, Astro, czysty React. Server Actions (z dyrektywą 'use server') wymagają frameworka wspierającego React Server Components, czyli np. Next.js z App Routerem.

Czy useActionState to to samo co useFormState?

useFormState to poprzednia nazwa API z wydań Canary. W stabilnym React 19 używaj useActionState, który zwraca trójkę [state, formAction, isPending].

Kiedy używać Actions, a kiedy React Hook Form?

Actions sprawdzają się przy prostych formularzach zorientowanych na serwer takim jak newsletter, kontakt albo zmiana danych profilu. React Hook Form nadal wygrywa przy złożonych formularzach z walidacją na żywo, polami dynamicznymi i zagnieżdżonymi listami po stronie klienta.

Jak useOptimistic różni się od useState?

useOptimistic wyświetla tymczasową wersję UI podczas trwania akcji. Po jej zakończeniu React renderuje przekazany stan bazowy, dlatego po sukcesie trzeba go zaktualizować danymi potwierdzonymi przez serwer. Przy błędzie, bez zmiany stanu bazowego, tymczasowa zmiana znika.

Co się dzieje, gdy akcja rzuci wyjątkiem?

React przekieruje wyjątek do najbliższego Error Boundary. Oczekiwane problemy, takie jak błędy walidacji lub odpowiedź odrzucona przez API, lepiej zwracać jako stan, np. { error: 'Coś poszło nie tak' }. Nieoczekiwane awarie mogą pozostać wyjątkami obsługiwanymi przez Error Boundary.

Czy useOptimistic robi automatyczny rollback przy błędzie?

Nie w sensie aktywnej obsługi błędu. useOptimistic pokazuje stan tymczasowy w trakcie akcji, a po jej zakończeniu renderuje stan bazowy, który aktualizujesz tylko po sukcesie. Gdy akcja zawiedzie i stanu bazowego nie zmienisz, optymistyczna zmiana po prostu znika, bo widok wraca do wartości sprzed niej. Komunikat o błędzie (toast, Error Boundary) musisz dodać sam.

Kiedy NIE używać Optimistic UI?

Przy płatnościach i transakcjach finansowych (użytkownik musi widzieć potwierdzenie serwera), operacjach nieodwracalnych (usunięcie konta, wysyłka zamówienia), danych wymagających walidacji serwerowej (unikalne slugi, dostępność) i operacjach długo przetwarzanych (raporty, pliki). Tam użyj realnego isPending z loading state zamiast optymistycznej zmiany.

Czy Actions wspierają Progressive Enhancement?

Tak, gdy formularz korzysta z Server Function oznaczonej 'use server' w środowisku frameworka wspierającego tę funkcję. Taki formularz może zostać wysłany przed załadowaniem JavaScriptu albo przy wyłączonym JS, ale nadal wymaga połączenia z serwerem.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web 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 rozwija 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 Next.js

Czytaj dalej

Zobacz więcej wpisów
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

React Server Components i Server Actions: SEO i wydajność

App Router pozwala przesunąć pobieranie danych i renderowanie treści na serwer, zostawiając w przeglądarce małe obszary interakcji. Taki podział może ograniczyć JavaScript i ułatwić dostarczenie treści w HTML, ale nie gwarantuje dobrych Core Web Vitals ani indeksacji. Wynik zależy też od czasu odpowiedzi serwera, cache, obrazów, fontów, metadanych i bezpieczeństwa mutacji.

Maciej Sala

Maciej Sala

Founder StriveLab

Route Handlers czy Server Actions? Kiedy co wybrać

App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.

Maciej Sala

Maciej Sala

Founder StriveLab