React 19's Actions Changed How We Build Forms (And Cut Our Code in Half)

By amillionmonkeys
#React#Forms#Web Development

How React 19's new Actions, useOptimistic, and useActionState hooks simplified our form handling in production apps, with before/after code examples.

We've been building forms in React for over a decade. Loading states, error handling, optimistic updates, validation—every single form needed the same boilerplate. We'd write 80 lines of code for what should be 20.

Then React 19 dropped Actions, useActionState, and useOptimistic. We refactored a client dashboard with 12 forms. The result? We deleted 1,200 lines of code and the forms work better than before.

Here's what actually changed, what we learned migrating real production apps, and the gotchas we hit along the way.

The Old Way: Form Handling Was Exhausting

Before React 19, even a simple form required orchestrating multiple pieces. Let's look at a typical contact form we built for a client in 2023:

// The old way - 60+ lines for a basic form
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 response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name, email, message }),
      });
 
      if (!response.ok) {
        throw new Error('Failed to send message');
      }
 
      setSuccess(true);
      setName('');
      setEmail('');
      setMessage('');
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Something went wrong');
    } finally {
      setIsSubmitting(false);
    }
  };
 
  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        disabled={isSubmitting}
        required
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        disabled={isSubmitting}
        required
      />
      <textarea
        value={message}
        onChange={(e) => setMessage(e.target.value)}
        disabled={isSubmitting}
        required
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Sending...' : 'Send Message'}
      </button>
      {error && <p className="error">{error}</p>}
      {success && <p className="success">Message sent!</p>}
    </form>
  );
}

That's 60 lines for three inputs. We managed seven state variables. We handled loading, errors, and success separately. Multiply this by 12 forms in a dashboard and you're maintaining 700+ lines of nearly identical code.

The New Way: Actions Changed Everything

React 19's Actions treat async functions as first-class citizens. Combined with useActionState, the same form becomes this:

// The new way - 25 lines for the same functionality
async function submitContact(prevState: any, formData: FormData) {
  try {
    const response = await fetch('/api/contact', {
      method: 'POST',
      body: formData,
    });
 
    if (!response.ok) {
      return { error: 'Failed to send message' };
    }
 
    return { success: true };
  } catch (err) {
    return { error: 'Something went wrong' };
  }
}
 
function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, null);
 
  return (
    <form action={formAction}>
      <input name="name" required disabled={isPending} />
      <input name="email" type="email" required disabled={isPending} />
      <textarea name="message" required disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send Message'}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">Message sent!</p>}
    </form>
  );
}

From 60 lines to 25. No state management for inputs (FormData handles it). Loading state built-in (isPending). Error handling streamlined.

We've now refactored 12 forms across three client projects with this pattern. Every single one got shorter and easier to reason about.

What useOptimistic Actually Gives You

The documentation makes useOptimistic sound magical. In practice, it's perfect for one specific thing: showing users instant feedback while the server does its work.

We built a task management dashboard for a client. Users kept double-clicking "complete task" because there was no immediate feedback. Here's how useOptimistic fixed it:

function TaskList({ tasks }: { tasks: Task[] }) {
  const [optimisticTasks, setOptimisticTasks] = useOptimistic(
    tasks,
    (state, completedId: string) =>
      state.map(task =>
        task.id === completedId
          ? { ...task, completed: true }
          : task
      )
  );
 
  async function completeTask(taskId: string) {
    setOptimisticTasks(taskId); // Instant UI update
    await updateTaskOnServer(taskId); // Actual update
  }
 
  return (
    <ul>
      {optimisticTasks.map(task => (
        <li key={task.id} className={task.completed ? 'done' : ''}>
          {task.name}
          {!task.completed && (
            <button onClick={() => completeTask(task.id)}>
              Complete
            </button>
          )}
        </li>
      ))}
    </ul>
  );
}

The task appears completed immediately. If the server fails, React automatically reverts it. No more double-clicks, no more spinner anxiety.

We measured this: task completion actions went from 2.3 seconds perceived time (spinner visible) to 0.1 seconds (instant feedback). Users stopped double-clicking.

Combining Actions with Server Components

Here's where it gets interesting for Next.js projects. We're building most client dashboards with Server Components now, and Actions slot right in:

// app/actions.ts - Server Action
'use server'
 
import { revalidatePath } from 'next/cache';
 
export async function addComment(formData: FormData) {
  const comment = formData.get('comment') as string;
  const postId = formData.get('postId') as string;
 
  await db.comments.create({
    data: { comment, postId }
  });
 
  revalidatePath(`/posts/${postId}`);
  return { success: true };
}
 
// app/posts/[id]/page.tsx - Server Component
import { addComment } from '@/app/actions';
 
export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.posts.findUnique({ where: { id: params.id } });
  const comments = await db.comments.findMany({ where: { postId: params.id } });
 
  return (
    <div>
      <h1>{post.title}</h1>
      <CommentList comments={comments} />
      <CommentForm postId={params.id} action={addComment} />
    </div>
  );
}
 
// components/CommentForm.tsx - Client Component
'use client'
 
import { useActionState } from 'react';
 
export function CommentForm({
  postId,
  action
}: {
  postId: string;
  action: (formData: FormData) => Promise<any>;
}) {
  const [state, formAction, isPending] = useActionState(action, null);
 
  return (
    <form action={formAction}>
      <input type="hidden" name="postId" value={postId} />
      <textarea name="comment" required disabled={isPending} />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Posting...' : 'Add Comment'}
      </button>
      {state?.success && <p>Comment added!</p>}
    </form>
  );
}

The form posts directly to a Server Action. revalidatePath refreshes the page data. No API routes, no client-side fetching, no cache invalidation headaches.

We've replaced 15+ API routes across projects with Server Actions. Less code, better type safety (you're just calling functions), and Next.js handles the caching.

The Gotchas We Hit

1. FormData Only Contains Strings

This bit us on a multi-file upload form:

// This doesn't work like you'd expect
async function uploadFiles(formData: FormData) {
  const files = formData.getAll('files'); // Returns File[] | string[]
 
  // files[0].size - TypeScript error! Could be string
}
 
// You need to check types
async function uploadFiles(formData: FormData) {
  const files = formData.getAll('files').filter(
    (file): file is File => file instanceof File
  );
 
  // Now files is definitely File[]
  const totalSize = files.reduce((sum, file) => sum + file.size, 0);
}

2. Error Boundaries Don't Catch Action Errors

We expected errors in Actions to bubble to error boundaries. They don't:

// This won't trigger error boundary
async function submitForm(formData: FormData) {
  throw new Error('Something broke'); // Unhandled!
}
 
// You must handle errors in the Action
async function submitForm(formData: FormData) {
  try {
    // ... your code
  } catch (err) {
    return { error: err.message }; // Return errors, don't throw
  }
}

3. Progressive Enhancement Requires More Work

Actions work without JavaScript, but you need to handle the response properly:

// Won't work without JS - useActionState requires client-side React
function Form() {
  const [state, formAction, isPending] = useActionState(submitForm, null);
  return <form action={formAction}>...</form>;
}
 
// For progressive enhancement, return redirect in Action
async function submitForm(formData: FormData) {
  // ... handle form
  redirect('/success'); // Works without JS
}

Most of our projects run with JavaScript, so we don't worry about this. But if you need true progressive enhancement, you'll write Actions differently.

How Much Code We Actually Cut

We tracked this across three client projects where we migrated forms to React 19 Actions:

  • E-commerce dashboard (8 forms): 980 lines → 420 lines (57% reduction)
  • Task management app (12 forms): 1,340 lines → 580 lines (56% reduction)
  • Content CMS (6 forms): 620 lines → 310 lines (50% reduction)

Average: 54% less code for the same functionality, with better UX from useOptimistic.

Those aren't just fewer lines—they're fewer bugs, faster reviews, and easier onboarding when new developers join projects.

What We're Using Actions For Now

Forms, obviously. But also:

  • Inline editing: Click to edit, auto-save on blur
  • Bulk operations: "Delete selected" with optimistic removal
  • File uploads: Progress tracking with isPending
  • Data tables: Sort, filter, paginate with URL state

Anywhere we previously wrote useState + useEffect + fetch logic, we now reach for Actions first.

Should You Migrate Your Existing Forms?

We did, but we were selective. Here's our decision tree:

Migrate if:

  • Form has complex loading/error/success states
  • You're already on React 19
  • Form is actively being worked on

Don't migrate if:

  • Form is simple (1-2 fields, no server interaction)
  • Form is in a stable part of the codebase
  • You're not on React 19 yet (it's not worth upgrading just for this)

We migrated our most complex forms first—the ones with 10+ fields, multi-step flows, file uploads. Those saw the biggest wins.

The Migration Process

For a typical form, migration took 15-20 minutes:

  1. Convert submit handler to Action function
  2. Replace useState with useActionState
  3. Remove controlled inputs (use name attribute + FormData)
  4. Replace loading state with isPending
  5. Update error handling to return from Action
  6. Add useOptimistic if needed for instant feedback
  7. Test both success and error paths

We did one form per day initially, learning the patterns. By the third project, we were knocking out three forms in an afternoon.

What's Next for Forms in React

Actions feel like the beginning, not the end. We're watching:

  • Server Components + Actions: The combination is powerful, still evolving
  • Form validation: Still using libraries (Zod, React Hook Form), waiting for built-in solutions
  • Type safety: FormData is stringly-typed, would love better type inference

For now, Actions have genuinely changed how we build forms. Less code, better UX, fewer bugs. That's a rare combination.

Planning a React 19 Upgrade?

We've now migrated six production apps to React 19, ranging from small dashboards to complex e-commerce platforms. Every migration hit different edge cases around Suspense boundaries, Server Components, and form handling.

If you're planning an upgrade and want to avoid the gotchas we discovered, get in touch. We're a small studio in Brighton building bespoke web applications and we're happy to share what we've learned.

Want to see how we're using React 19 in production? Check out our recent work or read our other development articles.

T: 07512 944360 | E: [email protected]

© 2025 amillionmonkeys ltd. All rights reserved.