useActionState w React 19 — koniec z ręcznym zarządzaniem stanem formularzy

useActionState hook w React 19 — porównanie kodu przed i po

Hook useActionState w React 19, który zmienia sposób obsługi formularzy. Jeśli piszecie formularze w React od jakiegoś czasu, to pewnie znacie ten moment — zaczynacie nowy komponent i niemal odruchowo dopisujecie useState dla pending, useState dla błędu, useState dla sukcesu. A potem try/catch/finally. A potem resetowanie tych flag przed kolejnym wysłaniem. I tak za każdym razem. 😅

React 19 wprowadza useActionState — hook, który bierze na siebie całą tę powtarzalną część. Dzisiaj pokażę Wam dokładnie, co zmienia i jak go używać. 🙂

🤔 Zanim przejdziemy do hooka — czym jest „akcja”?

W React 19 pojawia się nowa terminologia. Akcja to asynchroniczna funkcja obsługująca mutację danych — czyli to, co wcześniej pisaliście w handleSubmit. Różnica polega na tym, że zamiast przekazywać ją do onSubmit, przekazujecie ją bezpośrednio do atrybutu action na formularzu:

// React 18
<form onSubmit={handleSubmit}>

// React 19
<form action={myAction}>

To celowe nawiązanie do natywnego HTML-owego action — dzięki temu formularze mogą działać poprawnie nawet bez JavaScriptu (progressive enhancement). W typowej aplikacji client-side różnica jest głównie konceptualna, ale warto wiedzieć skąd to pochodzi. 😊

😬 Jak to wyglądało w React 18

Weźmy klasyczny przykład — formularz zmiany nazwy użytkownika. Potrzebujemy obsłużyć pending, błąd i sukces:

function UpdateNameForm() {
  const [name, setName] = useState('');
  const [isPending, setIsPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();

    if (!name.trim()) {
      setError('Nazwa nie może być pusta.');
      return;
    }

    setIsPending(true);
    setError(null);
    setSuccess(false);

    try {
      await updateUserName(name);
      setSuccess(true);
      setName('');
    } catch (err) {
      setError('Nie udało się zaktualizować nazwy. Spróbuj ponownie.');
    } finally {
      setIsPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Nowa nazwa</label>
      <input
        id="name"
        value={name}
        onChange={e => setName(e.target.value)}
        disabled={isPending}
      />
      {error && <p className="error">{error}</p>}
      {success && <p className="success">Nazwa została zmieniona!</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Zapisywanie...' : 'Zapisz'}
      </button>
    </form>
  );
}

Cztery useStatetry/catch/finally, ręczne zerowanie flag przed każdym wysłaniem — i to przy jednym polu. W prawdziwej aplikacji dochodzi walidacja, kilka pól, różne kody błędów z API. Kod rośnie szybko. 📈

✨ Jak to wygląda z useActionState

import { useActionState } from 'react';

type State = {
  error: string | null;
  success: boolean;
};

async function updateNameAction(
  prevState: State,
  formData: FormData
): Promise<State> {
  const name = formData.get('name') as string;

  if (!name.trim()) {
    return { error: 'Nazwa nie może być pusta.', success: false };
  }

  try {
    await updateUserName(name);
    return { error: null, success: true };
  } catch {
    return {
      error: 'Nie udało się zaktualizować nazwy. Spróbuj ponownie.',
      success: false,
    };
  }
}

function UpdateNameForm() {
  const [state, action, isPending] = useActionState(updateNameAction, {
    error: null,
    success: false,
  });

  return (
    <form action={action}>
      <label htmlFor="name">Nowa nazwa</label>
      <input id="name" name="name" disabled={isPending} />
      {state.error && <p className="error">{state.error}</p>}
      {state.success && <p className="success">Nazwa została zmieniona!</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Zapisywanie...' : 'Zapisz'}
      </button>
    </form>
  );
}

Przejdźmy przez to krok po kroku. 👇

Sygnatura hooka

const [state, action, isPending] = useActionState(actionFn, initialState);

Hook przyjmuje funkcję akcji i stan początkowy. Zwraca trójkę: aktualny stan, funkcję do przekazania do formularza i isPending. React zarządza pending state samodzielnie — nie musicie tego robić ręcznie. 🎉

Funkcja akcji

async function updateNameAction(
  prevState: State,   // poprzedni stan
  formData: FormData  // dane z formularza
): Promise<State> {
  // ...
  return newState; // to trafi do `state` w komponencie
}

Zwróćcie uwagę, że funkcja akcji żyje poza komponentem. Nie ma dostępu do stanu przez zamknięcie (closure), co sprawia, że jest łatwiejsza do testowania i reużywania. Dane dostaje przez FormData, wynik komunikuje przez wartość zwracaną.

FormData zamiast kontrolowanych inputów

Mnie osobiście bardzo podoba się ta zmiana 😊 — zamiast trzymać wartość każdego pola w stanie komponentu, wystarczy dać inputowi atrybut name, a React sam zbierze dane przy wysyłaniu:

// Zamiast tego:
const [name, setName] = useState('');
<input value={name} onChange={e => setName(e.target.value)} />

// Wystarczy:
<input name="name" />
// i w akcji: formData.get('name')

Przy formularzach z wieloma polami robi to sporą różnicę. 👌

🔍 Walidacja i kilka komunikatów błędów

Rzadko macie jeden komunikat błędu dla całego formularza. Zobaczcie jak useActionState sprawdza się z bardziej rozbudowaną walidacją:

type FormState = {
  errors: {
    name?: string;
    email?: string;
    general?: string;
  };
  success: boolean;
};

async function updateProfileAction(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const name = (formData.get('name') as string).trim();
  const email = (formData.get('email') as string).trim();

  const errors: FormState['errors'] = {};

  if (!name) errors.name = 'Imię jest wymagane.';
  if (name.length > 50) errors.name = 'Imię nie może przekraczać 50 znaków.';
  if (!email) errors.email = 'Email jest wymagany.';
  if (!email.includes('@')) errors.email = 'Podaj prawidłowy adres email.';

  if (Object.keys(errors).length > 0) {
    return { errors, success: false };
  }

  try {
    await updateProfile({ name, email });
    return { errors: {}, success: true };
  } catch {
    return {
      errors: { general: 'Wystąpił błąd. Spróbuj ponownie.' },
      success: false,
    };
  }
}

function ProfileForm() {
  const [state, action, isPending] = useActionState(updateProfileAction, {
    errors: {},
    success: false,
  });

  return (
    <form action={action}>
      <div>
        <label htmlFor="name">Imię</label>
        <input id="name" name="name" disabled={isPending} />
        {state.errors.name && (
          <p className="field-error">{state.errors.name}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" disabled={isPending} />
        {state.errors.email && (
          <p className="field-error">{state.errors.email}</p>
        )}
      </div>

      {state.errors.general && (
        <p className="error">{state.errors.general}</p>
      )}
      {state.success && (
        <p className="success">Profil został zaktualizowany!</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Zapisywanie...' : 'Zapisz zmiany'}
      </button>
    </form>
  );
}

Stan to zwykły obiekt — możecie go ukształtować dokładnie tak, jak potrzebujecie. Hook nie narzuca żadnej struktury. 💪

🔄 Do czego służy prevState?

Pierwszy argument funkcji akcji to poprzedni stan. Przydaje się na przykład gdy chcecie zachować część danych przy błędzie, albo akumulować wyniki kolejnych wywołań — np. przy liście, do której kolejne akcje dodają elementy:

type ListState = {
  items: string[];
  error: string | null;
};

async function addItemAction(
  prevState: ListState,
  formData: FormData
): Promise<ListState> {
  const item = formData.get('item') as string;

  try {
    await saveItem(item);
    return {
      items: [...prevState.items, item], // zachowujemy poprzednie elementy
      error: null,
    };
  } catch {
    return {
      ...prevState, // zachowujemy cały poprzedni stan
      error: 'Nie udało się dodać elementu.',
    };
  }
}

⚠️ Na co uważać

useActionState importujemy z react, nie z react-dom 👀

// ✅
import { useActionState } from 'react';

To łatwo przeoczyć, szczególnie że useFormStatus (o którym napiszę w kolejnym wpisie) importuje się właśnie z react-dom.

Dane z FormData są zawsze stringami 🧵

formData.get() zwraca string | File | null, więc liczby i booleany wymagają ręcznej konwersji:

const age = Number(formData.get('age'));
const isActive = formData.get('isActive') === 'on'; // checkboxy

Nieskontrolowane inputy nie resetują się automatycznie 🔁

Po udanej akcji pola tekstowe zostają wypełnione. Najprostszy sposób na reset to zmiana key formularza:

<form key={state.success ? 'reset' : 'active'} action={action}>

Zmiana key powoduje odmontowanie i ponowne zamontowanie formularza — prosto i skutecznie.

Funkcja akcji powinna być stabilna 📌

Jeśli definiujecie akcję wewnątrz komponentu, użyjcie useCallback albo — najlepiej — wynieście ją poza komponent. Przy każdym renderze powstaje nowa referencja, co może powodować nieprzewidywalne zachowanie.

🤷 Kiedy useActionState nie jest potrzebny?

Nie każdy formularz go wymaga. Jeśli macie:

  • 🖊️ formularz z walidacją w locie (sprawdzanie długości pola podczas pisania) — kontrolowane inputy ze zwykłym useState są prostsze
  • 🚫 formularz bez komunikacji z API — nie potrzebujecie obsługi pending ani błędów sieciowych
  • 🧩 bardzo złożony stan formularza — warto rozważyć biblioteki jak React Hook Form, które mają więcej opcji

useActionState sprawdza się najlepiej wtedy, gdy formularz wysyła dane do API i musicie obsłużyć loading, błędy i wynik — a to w praktyce dotyczy zdecydowanej większości formularzy.

📋 Podsumowanie

React 18React 19 + useActionState
⏳ Pending stateuseState + ręczne setIsPendingTrzeci element z hooka
❌ Error handlingtry/catch + useStateStan zwracany z akcji
🔄 Reset flag przed kolejnym wysłaniemRęczne setError(null) itp.Automatycznie przy każdym wywołaniu
📬 Dane z formularzaKontrolowane inputy lub e.targetFormData — natywnie
🧪 Testowalność logikiPowiązana ze stanem komponentuCzysta funkcja poza komponentem

useActionState nie zmienia tego, co robi Wasz kod — logikę biznesową i tak musicie napisać sami. Zmienia to, gdziesiedzi cały zarządzający stan boilerplate. I szczerze, dobrze mu tam. 🙂

W kolejnym wpisie zajmę się useFormStatus — hookiem, który rozwiązuje problem przekazywania pending state do zagnieżdżonych komponentów bez props drillingu. Jeśli zdarzyło Wam się przekazywać isPending przez kilka poziomów tylko po to, żeby przycisk submit wiedział, że ma być zablokowany — to będzie post dla Was. 😊


Używacie już useActionState w projektach? Ciekawa jestem jak sprawdza się przy bardziej złożonych formularzach — szczególnie jak radzicie sobie z walidacją po stronie klienta. 👇

You might also like