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

Opublikowano
24 kwietnia 2026
Aktualizacja
24 czerwca 2026
Czas czytania
8 min czytania

Czym są Actions

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 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:

  • state — ostatni wynik zwrócony przez akcję (przy pierwszym renderze to wartość inicjalna, tu {}),
  • formAction — opakowany handler gotowy do wpisania w <form action={...}>,
  • isPending — 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 — 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 — 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 — natychmiastowy feedback 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).

Wariant 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 vs 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 — użytkownik musi zobaczyć realne potwierdzenie serwera, nie optymistyczną obietnicę;
  • operacji nieodwracalnych — usunięcie konta, wysłanie zamówienia;
  • danych wymagających walidacji serwerowej — unikalne slugi, sprawdzanie dostępności;
  • operacji długo przetwarzanych — generowanie raportu, 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 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

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ę.

Typowe pułapki

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ć 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) — 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 — brak natywnego API do odczytu procentowego postępu przesyłania.

Migracja z onSubmit

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 je 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.

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.

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

Actions sprawdzają się przy prostych formularzach zorientowanych na serwer — newsletter, kontakt, 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.

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.

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.

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.

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.

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 — 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 rozwija własne projekty side-projecty.

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

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów