DEV Community

Cover image for UI protection not equal system correctness
Bishoy Bishai
Bishoy Bishai

Posted on Originally published at bishoy-bishai.github.io

UI protection not equal system correctness

Let me show you something that will ruin your confidence in every form you've ever shipped.

Open your browser. Open any React form you've built with the standard pattern isSubmitting state, disabled={isSubmitting} on the button. Now open DevTools. Go to the Performance tab. Throttle the CPU to 4x slowdown.

Submit the form. While the page is processing the state update in that brief window between the click handler firing and React committing the render click the button again.

Both submissions go through.

I know. I've seen this happen in production. Not in a contrived test in a real checkout flow, with real orders, with real money. Two identical orders placed within 200 milliseconds of each other by one user with one click. The disabled attribute never appeared between those two clicks because the state update hadn't rendered yet.

The reason is simple once you see it: isSubmitting is a React state variable. React state updates are asynchronous and batched. There is a window small, measurable in milliseconds, but real between when you call setIsSubmitting(true) and when React commits that change to the DOM. In that window, the button is still enabled. A fast user, a slow render, a CPU under load any of these can create a double submission that your disabled flag was supposed to prevent.

This is not a theoretical edge case. It's a timing vulnerability in a pattern that every React developer uses by default, including me.

The Race Condition, Precisely

Let me show you the exact timing vulnerability, with code and a timeline.

// The standard pattern — and its vulnerability window

function OrderForm() {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 1️⃣ You call setIsSubmitting here
    setIsSubmitting(true);

    // ⚠️ THE WINDOW: Between step 1 and step 2, the button is STILL ENABLED.
    // React hasn't re-rendered yet. The DOM hasn't been updated.
    // On a throttled CPU or a complex component tree, this window
    // can be 50-200ms — long enough for a second click to fire.

    // 2️⃣ React renders, button becomes disabled
    // (This happens asynchronously, after the next render cycle)

    try {
      const formData = new FormData(e.currentTarget);
      await placeOrder(formData);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Order failed');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="productId" type="hidden" value="PRD-001" />
      <input name="quantity" type="number" defaultValue={1} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Placing Order...' : 'Place Order'}
      </button>
      {error && <p className="error">{error}</p>}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The timeline of a double submission:

T+0ms:    User clicks button (first click)
T+0ms:    handleSubmit fires
T+0ms:    setIsSubmitting(true) called
          ↑ React schedules a re-render, but hasn't committed it yet

T+15ms:   User's finger bounces (or they click again intentionally)
T+15ms:   handleSubmit fires AGAIN — button is still enabled in DOM
T+15ms:   setIsSubmitting(true) called again (already true, no-op)
T+15ms:   SECOND API CALL FIRES

T+50ms:   React commits the re-render — button is now disabled
          ↑ Too late. Both requests are already in flight.

T+1200ms: First API response: order placed (ORDER-001)
T+1250ms: Second API response: order placed (ORDER-002)
          ↑ Two identical orders. One user. One click.
Enter fullscreen mode Exit fullscreen mode

This is the gap. And it exists in every form using this pattern.


What useActionState Does Differently

useActionState closes this gap not by making the render faster, but by moving the responsibility for preventing concurrent submissions from your application code to React's scheduler.

The mental model shift: with isSubmitting, you are trying to prevent double submissions by managing state. With useActionState, React prevents double submissions by design — only one action can be pending at a time for a given form action, guaranteed at the framework level, before any render happens.

// useActionState signature
const [state, formAction, isPending] = useActionState(action, initialState);

// action: async function that receives (previousState, formData) and returns newState
// formAction: pass this to <form action={formAction}>
// isPending: true while action is running — React-managed, not state-managed
// state: whatever your action function returned last
Enter fullscreen mode Exit fullscreen mode

The key difference for isPending vs isSubmitting:

  • isSubmitting → client state, subject to render cycle timing
  • isPending → React scheduler flag, set synchronously when the action starts, before any render

When you pass formAction to the form's action prop and a user submits, React marks the action as pending immediately — not after a render. If another submission attempt happens before the first completes, React's form action system prevents it from firing the action function at all. The gap is closed at the invocation level, not at the UI level.


Building the Complete Pattern

Let me build a realistic form — not a login demo with one field, but an order placement form with field validation, error handling, success state, and loading feedback. This is the kind of form that breaks in production.

// types.ts
interface OrderFormState {
  status: 'idle' | 'error' | 'success';
  errors: {
    quantity?: string;
    address?: string;
    form?: string;
  };
  orderId?: string;
}
Enter fullscreen mode Exit fullscreen mode
// actions/placeOrder.ts
// The action function — lives outside the component.
// Can be a Server Action in Next.js, or a client-side async function.
// Receives: previous state + FormData
// Returns: the new state

async function placeOrderAction(
  previousState: OrderFormState,
  formData: FormData
): Promise<OrderFormState> {
  // Extract and validate fields
  const quantity = parseInt(formData.get('quantity') as string);
  const address = (formData.get('address') as string)?.trim();

  // Field-level validation — return errors without hitting the API
  const errors: OrderFormState['errors'] = {};

  if (!quantity || quantity < 1) {
    errors.quantity = 'Quantity must be at least 1';
  }
  if (quantity > 100) {
    errors.quantity = 'Maximum 100 items per order';
  }
  if (!address) {
    errors.address = 'Delivery address is required';
  }
  if (address && address.length < 10) {
    errors.address = 'Please enter a complete address';
  }

  if (Object.keys(errors).length > 0) {
    return { status: 'error', errors };
  }

  // Submit to API
  try {
    const response = await fetch('/api/orders', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ quantity, address }),
    });

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      return {
        status: 'error',
        errors: {
          form: errorData.message || `Order failed: ${response.status}`,
        },
      };
    }

    const { orderId } = await response.json();
    return { status: 'success', errors: {}, orderId };

  } catch (err) {
    // Network error — not a validation error, not a server rejection
    // The user should know they might not have connectivity
    return {
      status: 'error',
      errors: {
        form: 'Could not reach the server. Please check your connection and try again.',
      },
    };
  }
}
Enter fullscreen mode Exit fullscreen mode
// components/OrderForm.tsx

import { useActionState } from 'react';
import { placeOrderAction } from '../actions/placeOrder';

const initialState: OrderFormState = {
  status: 'idle',
  errors: {},
};

export function OrderForm({ productId }: { productId: string }) {
  const [state, formAction, isPending] = useActionState(
    placeOrderAction,
    initialState
  );

  // Success state — show confirmation, not the form
  if (state.status === 'success') {
    return (
      <div role="alert" aria-live="polite">
        <h2>Order Placed</h2>
        <p>Your order <strong>{state.orderId}</strong> has been confirmed.</p>
        <p>You'll receive a confirmation email shortly.</p>
      </div>
    );
  }

  return (
    // Pass formAction directly to the form's action prop.
    // This is what gives React control over submission lifecycle.
    // No onSubmit handler. No e.preventDefault(). React handles it.
    <form action={formAction} noValidate>
      <input type="hidden" name="productId" value={productId} />

      {/* Form-level error — API failure, network error */}
      {state.errors.form && (
        <div role="alert" className="error-banner">
          {state.errors.form}
        </div>
      )}

      <div className="field">
        <label htmlFor="quantity">
          Quantity
          {/* Field-level error — inline, associated with the input */}
          {state.errors.quantity && (
            <span className="field-error" id="quantity-error">
              {state.errors.quantity}
            </span>
          )}
        </label>
        <input
          id="quantity"
          name="quantity"
          type="number"
          min={1}
          max={100}
          defaultValue={1}
          disabled={isPending}
          // aria-describedby links the error message to the input for screen readers
          aria-describedby={state.errors.quantity ? 'quantity-error' : undefined}
          aria-invalid={!!state.errors.quantity}
        />
      </div>

      <div className="field">
        <label htmlFor="address">
          Delivery Address
          {state.errors.address && (
            <span className="field-error" id="address-error">
              {state.errors.address}
            </span>
          )}
        </label>
        <textarea
          id="address"
          name="address"
          rows={3}
          disabled={isPending}
          aria-describedby={state.errors.address ? 'address-error' : undefined}
          aria-invalid={!!state.errors.address}
          placeholder="Enter your full delivery address"
        />
      </div>

      <button
        type="submit"
        disabled={isPending}
        // aria-busy signals to assistive technology that the button
        // is processing — more semantically correct than just disabled
        aria-busy={isPending}
      >
        {isPending ? 'Placing Order...' : 'Place Order'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice what's missing compared to the "standard" approach:

  • No useState for isSubmitting
  • No useState for error
  • No useState for success
  • No e.preventDefault() in a handler
  • No try/catch in the component
  • No finally { setIsSubmitting(false) }

The component only knows two things: what state the action returned, and whether it's pending. All the async management lives in the action function — which is a plain async function, testable in isolation, framework-agnostic, reusable.


The TypeScript Pattern That Makes State Explicit

The state returned from your action function is more powerful when it's a discriminated union rather than a bag of optional properties. This pattern makes the TypeScript compiler enforce correctness in your component.

// Instead of a flat interface with optional fields:
interface OrderFormState {
  status: 'idle' | 'error' | 'success';
  errors: { quantity?: string; address?: string; form?: string; };
  orderId?: string; // Only present on success, but TypeScript doesn't know that
}

// Use a discriminated union — TypeScript knows exactly what's available in each state:
type OrderFormState =
  | { status: 'idle' }
  | { status: 'error'; errors: { quantity?: string; address?: string; form?: string } }
  | { status: 'success'; orderId: string };

// In the component:
const [state, formAction, isPending] = useActionState(placeOrderAction, { status: 'idle' });

// Now TypeScript enforces:
if (state.status === 'success') {
  // state.orderId is guaranteed to exist here — TypeScript knows this
  console.log(state.orderId); // ✅ No optional chaining needed

  // state.errors is not accessible here — TypeScript prevents it
  // console.log(state.errors); // ❌ TypeScript error
}

if (state.status === 'error') {
  // state.errors is guaranteed to exist
  const fieldErrors = state.errors; // ✅

  // state.orderId is not accessible
  // const id = state.orderId; // ❌ TypeScript error
}
Enter fullscreen mode Exit fullscreen mode

The discriminated union turns your runtime state logic into compile-time guarantees. The component can't accidentally render state.orderId in an error state — TypeScript prevents it before it reaches the browser.


Working with Next.js Server Actions

This is where useActionState becomes genuinely architectural, not just ergonomic. In Next.js with the App Router, your action function can be a Server Action — a function that runs on the server, can directly access your database, and is called from the client as if it were a regular function.

// app/actions/placeOrder.ts
'use server'; // This directive makes it a Server Action

import { db } from '@/lib/database';
import { getCurrentUser } from '@/lib/auth';

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

export async function placeOrderServerAction(
  previousState: OrderState,
  formData: FormData
): Promise<OrderState> {
  // This runs on the SERVER. No API endpoint needed.
  // Direct database access. Real session validation.

  const user = await getCurrentUser();
  if (!user) {
    return { status: 'error', message: 'Please log in to place an order' };
  }

  const productId = formData.get('productId') as string;
  const quantity = parseInt(formData.get('quantity') as string);

  if (!productId || !quantity || quantity < 1) {
    return { status: 'error', message: 'Invalid order details' };
  }

  try {
    // Direct database call — no HTTP request from server to itself
    const order = await db.orders.create({
      data: {
        userId: user.id,
        productId,
        quantity,
        status: 'pending',
      },
    });

    return { status: 'success', orderId: order.id };

  } catch (err) {
    console.error('Order creation failed:', err);
    return { status: 'error', message: 'Order could not be placed. Please try again.' };
  }
}
Enter fullscreen mode Exit fullscreen mode
// app/products/[id]/OrderSection.tsx
'use client'; // The COMPONENT is a Client Component

import { useActionState } from 'react';
import { placeOrderServerAction } from '@/app/actions/placeOrder';

export function OrderSection({ productId }: { productId: string }) {
  // The action runs on the server. The component runs in the browser.
  // useActionState bridges them transparently.
  const [state, formAction, isPending] = useActionState(
    placeOrderServerAction,
    { status: 'idle' }
  );

  if (state.status === 'success') {
    return <OrderConfirmation orderId={state.orderId} />;
  }

  return (
    <form action={formAction}>
      <input type="hidden" name="productId" value={productId} />
      <input name="quantity" type="number" defaultValue={1} disabled={isPending} />
      <button type="submit" disabled={isPending} aria-busy={isPending}>
        {isPending ? 'Placing Order...' : 'Place Order'}
      </button>
      {state.status === 'error' && (
        <p role="alert">{state.message}</p>
      )}
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this eliminates: you don't need an API route (/api/orders), you don't need a fetch call in the client, you don't need to manage authentication headers on the client side. The Server Action runs on the server with full database access, and useActionState handles the round-trip transparently.

The security model: Server Actions are automatically protected against CSRF because Next.js validates that they're called from pages it generated. Your database credentials never touch the client bundle. The server-side code never ships to the browser.


useActionState + useOptimistic — The Full Pattern

For the smoothest possible UX — showing the result immediately, before the server confirms — combine both hooks.

// A comment section that feels instant

import { useActionState, useOptimistic } from 'react';

interface Comment {
  id: string;
  text: string;
  authorName: string;
  createdAt: string;
  isPending?: boolean; // Only exists in the optimistic layer
}

async function addCommentAction(
  previousState: { comments: Comment[] },
  formData: FormData
): Promise<{ comments: Comment[] }> {
  const text = formData.get('text') as string;

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

  if (!response.ok) throw new Error('Failed to add comment');

  const newComment: Comment = await response.json();
  return { comments: [...previousState.comments, newComment] };
}

function CommentSection({ initialComments }: { initialComments: Comment[] }) {
  const [state, formAction, isPending] = useActionState(
    addCommentAction,
    { comments: initialComments }
  );

  // useOptimistic adds a temporary layer on top of real state
  // The user sees their comment instantly; it's marked as pending
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    state.comments,
    (current, newText: string) => [
      ...current,
      {
        id: `temp-${Date.now()}`,
        text: newText,
        authorName: 'You',
        createdAt: new Date().toISOString(),
        isPending: true, // Visual indicator that it's not confirmed yet
      },
    ]
  );

  return (
    <div>
      <ul>
        {optimisticComments.map(comment => (
          <li
            key={comment.id}
            // Visual treatment for pending comments
            style={{ opacity: comment.isPending ? 0.6 : 1 }}
          >
            <strong>{comment.authorName}</strong>
            <p>{comment.text}</p>
            {comment.isPending && <small>Sending...</small>}
          </li>
        ))}
      </ul>

      <form
        action={async (formData) => {
          // Add the optimistic comment immediately
          addOptimisticComment(formData.get('text') as string);
          // Then fire the actual action
          await formAction(formData);
        }}
      >
        <textarea name="text" placeholder="Add a comment..." required />
        <button type="submit" disabled={isPending}>
          {isPending ? 'Posting...' : 'Post Comment'}
        </button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The user types a comment, clicks Post, and sees it appear immediately in the list (with a "Sending..." indicator). When the server confirms, the temporary entry is replaced with the real one. If the server rejects it, useOptimistic automatically reverts to the real state — the comment disappears from the list, and you can show an error.


Here's what I want to say that the "useActionState is convenient" framing misses:

The reason useActionState exists is that form submission is a fundamentally different interaction model than the one React was originally designed around.

React's original model: events → state → render → events. This loop works beautifully for UI interactions. But form submission is: user action → server round-trip → new application state. The server is not part of React's original loop. Coordinating between them manually — with isSubmitting, with try/catch, with separate error/success states — is application code compensating for a gap in the framework.

useActionState is React acknowledging that server state transitions are common enough and tricky enough to warrant first class support. The isPending guarantee is not a convenience it's the framework taking responsibility for a coordination problem that most developers were solving incorrectly.

The practical implication: if you're building any form that touches a server in React 19, useActionState is the right default not because it's shorter, but because the manual pattern has a correctness gap that most teams never close.


📚 for more

✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book: Surrounded by AI

Top comments (0)