Skip to main content
Adzbyte
Development

React 19 Actions: Replacing useEffect for Form Handling

Adrian Saycon
Adrian Saycon
March 9, 20264 min read
React 19 Actions: Replacing useEffect for Form Handling

If your React form components look like a chain of useState + useEffect + fetch + error state + loading state, React 19 has a better pattern. Actions and useActionState collapse all of that into a single hook that handles the async lifecycle for you.

The Old Way

Here’s a typical React 18 form component. Count the state variables:

function ContactForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [message, setMessage] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);
    setError(null);

    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, email, message }),
      });

      if (!res.ok) throw new Error('Failed to send');
      setSuccess(true);
      setName('');
      setEmail('');
      setMessage('');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Something went wrong');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p className="error">{error}</p>}
      {success && <p className="success">Message sent!</p>}
      <input value={name} onChange={e => setName(e.target.value)} />
      <input value={email} onChange={e => setEmail(e.target.value)} />
      <textarea value={message} onChange={e => setMessage(e.target.value)} />
      <button disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

Six state variables, manual error handling, manual loading state, manual form reset. It works, but it’s a lot of ceremony for a contact form.

The React 19 Way: useActionState

useActionState takes an async function and an initial state, then gives you the current state, a form action, and a pending flag:

import { useActionState } from 'react';

type FormState = {
  message: string;
  status: 'idle' | 'success' | 'error';
};

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

  const res = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) {
    return { message: 'Failed to send message.', status: 'error' };
  }

  return { message: 'Message sent!', status: 'success' };
}

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, {
    message: '',
    status: 'idle',
  });

  return (
    <form action={formAction}>
      {state.status === 'error' && <p className="error">{state.message}</p>}
      {state.status === 'success' && <p className="success">{state.message}</p>}
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button disabled={isPending}>
        {isPending ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

No useState for individual fields — the form uses native FormData. No manual loading state — isPending is provided. No try/catch — the action function returns state that describes the outcome. The component went from 40+ lines to 25.

Optimistic Updates with useOptimistic

React 19 also introduces useOptimistic for showing immediate UI feedback while an async action is in flight. Here’s a practical example — a like button:

import { useOptimistic, useActionState } from 'react';

function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    initialLikes,
    (current: number) => current + 1
  );

  async function handleLike(prevState: number, formData: FormData): Promise<number> {
    addOptimisticLike(optimisticLikes);

    const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
    const data = await res.json();
    return data.likes;
  }

  const [likes, formAction, isPending] = useActionState(handleLike, initialLikes);

  return (
    <form action={formAction}>
      <button type="submit" disabled={isPending}>
        {optimisticLikes} likes
      </button>
    </form>
  );
}

The like count increments instantly when clicked, then reconciles with the server response. If the request fails, it rolls back to the previous value automatically.

When to Use Actions vs. Traditional Handlers

Actions work best for form submissions and mutations — anything where you’re sending data and waiting for a result. For real-time interactions (drag and drop, canvas drawing, text editor cursors), traditional event handlers are still the right choice.

The mental model shift is thinking of forms as state machines rather than imperative event sequences. Your action function is pure: it takes previous state and form data, returns new state. React handles the async lifecycle around it.

Server Actions in Next.js

If you’re using Next.js, actions get even more powerful. Mark a function with 'use server' and it runs on the server — no API route needed:

'use server';

export async function submitContact(prevState: FormState, formData: FormData) {
  const email = formData.get('email') as string;

  // This runs on the server — safe to use DB, env vars, etc.
  await db.insert(contacts).values({
    name: formData.get('name') as string,
    email,
    message: formData.get('message') as string,
  });

  return { message: 'Sent!', status: 'success' as const };
}

The function executes server-side, but you use it in your client component with useActionState exactly the same way. No fetch calls, no API routes, no serialization code.

Adrian Saycon

Written by

Adrian Saycon

A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.

Discussion (0)

Sign in to join the discussion

No comments yet. Be the first to share your thoughts.