Skip to content

5.6 — Forms, Server Actions & optimistic UI

Module 5 · Lesson 6 · 🟡 Intermediate · ~35 min

What you'll learn

  • Server Actions: what they cost and when they beat a route handler
  • useOptimistic for add‑to‑cart that feels instant
  • Checkout form performance: controlled vs uncontrolled, validation, INP
  • Progressive enhancement — forms that work before hydration

Server Actions

A Server Action is a function that runs on the server, callable directly from a client component or a form. The benefit for performance is that the mutation logic never ships to the client.

// app/actions/cart.ts
'use server';

import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
import { z } from 'zod';

const AddToCartSchema = z.object({
  productId: z.string().min(1),
  variantId: z.string().min(1),
  quantity: z.coerce.number().int().min(1).max(10),
});

export async function addToCart(prevState: ActionState, formData: FormData): Promise<ActionState> {
  // ALWAYS validate on the server. Client-side validation is a UX feature,
  // not a security control — the action is a public HTTP endpoint.
  const parsed = AddToCartSchema.safeParse({
    productId: formData.get('productId'),
    variantId: formData.get('variantId'),
    quantity: formData.get('quantity'),
  });

  if (!parsed.success) {
    return { ok: false, error: 'Invalid selection' };
  }

  const cartId = (await cookies()).get('cart_id')?.value;

  try {
    const cart = await commerceApi.addLine(cartId, parsed.data);
    revalidateTag(`cart-${cart.id}`);
    return { ok: true, cartCount: cart.totalQuantity };
  } catch (err) {
    logError('add_to_cart_failed', { err, ...parsed.data });
    return { ok: false, error: 'Could not add to bag. Please try again.' };
  }
}

Server Actions vs Route Handlers:

Server Action Route Handler (/api/...)
Client JS cost ~0 (a reference, not the code) You write the fetch call
Works without JS ✅ With <form action>
Type safety ✅ End to end Manual
Request batching Sequential by default You control it
Called by external systems
Cacheable GET ❌ (POST only)

Use Server Actions for mutations from your own UI. Use Route Handlers for anything read‑heavy, cacheable, or called by something that isn't your React app (mobile apps, webhooks, partners).

The performance caveat

Server Actions are POST requests, and Next.js runs them sequentially — a second action waits for the first to complete. Rapid‑fire interactions (a quantity stepper tapped five times) queue up.

// ❌ Five taps = five sequential round trips = 5 × 180ms
<button onClick={() => updateQuantity(lineId, qty + 1)}>+</button>

// ✅ Debounce the server call, update the UI optimistically
'use client';
export function QuantityStepper({ lineId, initial }: Props) {
  const [qty, setQty] = useState(initial);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  const change = (delta: number) => {
    const next = Math.max(1, Math.min(10, qty + delta));
    setQty(next);                                   // instant UI

    clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      void updateQuantityAction(lineId, next);      // one call after the user stops
    }, 400);
  };

  return (
    <div className="flex items-center gap-2">
      <button onClick={() => change(-1)} aria-label="Decrease quantity">−</button>
      <span className="tabular-nums w-6 text-center">{qty}</span>
      <button onClick={() => change(1)} aria-label="Increase quantity">+</button>
    </div>
  );
}

Optimistic add‑to‑cart

The highest‑value perceived‑performance win on a commerce site. The cart badge should increment on tap, not 300 ms later.

// components/add-to-cart-form.tsx
'use client';
import { useOptimistic, useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { addToCart } from '@/app/actions/cart';

export function AddToCartForm({ product, variantId, cartCount }: Props) {
  const [state, formAction] = useActionState(addToCart, { ok: true } as ActionState);

  // The badge updates the instant the form is submitted
  const [optimisticCount, addOptimisticCount] = useOptimistic(
    cartCount,
    (current: number, added: number) => current + added,
  );

  return (
    <form
      action={async (formData) => {
        addOptimisticCount(Number(formData.get('quantity') ?? 1));
        await formAction(formData);
      }}
    >
      <input type="hidden" name="productId" value={product.id} />
      <input type="hidden" name="variantId" value={variantId} />
      <input type="hidden" name="quantity" value="1" />

      <SubmitButton />

      {/* Errors are announced, and the optimistic value reverts automatically
          when the action returns because React re-renders with the real value */}
      {state.ok === false && (
        <p role="alert" className="mt-2 text-sm text-red-600">{state.error}</p>
      )}

      <CartBadge count={optimisticCount} />
    </form>
  );
}

// useFormStatus must be in a CHILD of the form — it reads the parent form's state
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="h-12 w-full rounded-full bg-neutral-900 text-white disabled:opacity-70"
    >
      {pending ? 'Adding…' : 'Add to bag'}
    </button>
  );
}

The rules of optimistic UI in commerce:

  1. Only be optimistic about things that almost always succeed. Add‑to‑cart succeeds >99% of the time — be optimistic. Payment authorization does not — never be optimistic.
  2. Reverting must be visible and explained. A cart badge that silently drops back from 3 to 2 is worse than a 300 ms wait. Show an error.
  3. Never be optimistic about money. Order totals, tax, shipping, and discounts must come from the server. An optimistic total that changes at checkout destroys trust and generates support tickets.
  4. Keep the optimistic and real shapes identical, or the swap causes a layout shift.
Action Optimistic?
Add to cart ✅ Yes
Remove from cart ✅ Yes
Change quantity ✅ Yes
Add to wishlist ✅ Yes
Apply promo code ❌ No — it might be invalid, and it changes the total
Recalculate shipping ❌ No — money
Place order ❌ Absolutely not

Checkout form performance

Checkout is where INP matters most and where you have the least room for error.

Uncontrolled inputs by default

Controlled inputs re‑render the form on every keystroke. On a 20‑field checkout, that's a render per character.

// ❌ Controlled: every keystroke re-renders the whole form
const [form, setForm] = useState({ email: '', address1: '', city: '', /* … */ });
<input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />

// ✅ Uncontrolled: the browser owns the value; React never re-renders while typing.
//    FormData reads everything on submit.
<form action={submitAddress}>
  <input name="email" type="email" required autoComplete="email" defaultValue={saved.email} />
  <input name="address1" required autoComplete="address-line1" defaultValue={saved.address1} />
  <input name="city" required autoComplete="address-level2" defaultValue={saved.city} />
</form>

Use controlled inputs only where you genuinely need the value during typing — a card‑number formatter, a live postcode lookup, a character counter. Even then, isolate that field in its own component so the rest of the form doesn't re‑render.

// ✅ Isolated controlled field — re-renders only itself
function CardNumberField() {
  const [value, setValue] = useState('');
  return (
    <input
      name="cardNumber"
      inputMode="numeric"
      autoComplete="cc-number"
      value={value}
      onChange={(e) => setValue(formatCardNumber(e.target.value))}
    />
  );
}

Validation

// ✅ Native validation first — free, accessible, zero JS
<input
  name="email"
  type="email"
  required
  autoComplete="email"
  aria-describedby="email-error"
/>

// ✅ Validate on blur, not on every keystroke
<input
  name="postcode"
  onBlur={(e) => validatePostcode(e.target.value)}
  autoComplete="postal-code"
/>

Rules: - Validate on blur and on submit, never on every keystroke — per‑keystroke validation is a direct INP cost and it's hostile UX (errors appear while you're still typing). - Use native HTML validation for the common cases; it's free and screen‑reader friendly. - Server validation is mandatory regardless. Client validation is a UX nicety. - autoComplete attributes are a performance feature: autofill saves the user 20 interactions, which is worth more than any micro‑optimization on this page.

The autoComplete reference for checkout

<input autoComplete="email" />              // email
<input autoComplete="given-name" />         // first name
<input autoComplete="family-name" />        // last name
<input autoComplete="address-line1" />      // street
<input autoComplete="address-line2" />      // apt/unit
<input autoComplete="address-level2" />     // city
<input autoComplete="address-level1" />     // state/province
<input autoComplete="postal-code" />        // zip/postcode
<input autoComplete="country" />            // country
<input autoComplete="tel" />                // phone
<input autoComplete="cc-name" />            // cardholder
<input autoComplete="cc-number" inputMode="numeric" />
<input autoComplete="cc-exp" />             // MM/YY
<input autoComplete="cc-csc" inputMode="numeric" />
<input autoComplete="one-time-code" inputMode="numeric" />  // SMS OTP autofill

Also set inputMode so mobile keyboards show the right layout — a numeric keypad for card numbers saves several taps and reduces errors.


Progressive enhancement

Forms using <form action={serverAction}> work before JavaScript has loaded. On a commerce site with a 2.7 s hydration window, that's a real conversion difference for users who tap early.

// ✅ Works with JS disabled or not yet loaded: the browser posts the form natively.
//    With JS, React intercepts and does it without a navigation.
<form action={addToCart}>
  <input type="hidden" name="productId" value={product.id} />
  <button type="submit">Add to bag</button>
</form>

// ❌ Requires hydration. A tap before hydration does nothing.
<button onClick={() => addToCart(product.id)}>Add to bag</button>

Test it: disable JavaScript in DevTools and try to complete your core flows. On most commerce sites, add‑to‑cart and checkout should work. If they don't, every early tap is a lost interaction.

// Enhance progressively: the form works without JS, better with it
'use client';
export function AddToCartForm({ product }: Props) {
  const [optimisticCount, addOptimistic] = useOptimistic(/* … */);
  return (
    <form
      action={async (formData) => {
        addOptimistic(1);              // only runs when JS is available
        await addToCart(formData);
      }}
    >
      <input type="hidden" name="productId" value={product.id} />
      <SubmitButton />
    </form>
  );
}

Multi‑step checkout

// ✅ Each step is a route: cacheable shell, small bundle, real URLs,
//    working back button, and analytics that can see step drop-off
app/checkout/
├── layout.tsx           shared summary sidebar (Server Component)
├── information/page.tsx contact + shipping address
├── shipping/page.tsx    shipping method
└── payment/page.tsx     payment (the ONLY route that loads the payment SDK)

The critical win: the payment SDK loads only on /checkout/payment. Aurora's payment provider SDK is 140 KB; putting checkout in a single client‑side wizard meant every user downloaded it at step 1, and 34% never reached payment.

// app/checkout/payment/page.tsx — the SDK is scoped to this route
import dynamic from 'next/dynamic';

const PaymentElement = dynamic(() => import('@/components/payment-element'), {
  ssr: false,                                  // the SDK needs window
  loading: () => <PaymentSkeleton />,          // sized to prevent CLS
});

export default function PaymentStep() {
  return (
    <>
      <OrderSummary />          {/* Server Component */}
      <PaymentElement />
    </>
  );
}

Preconnect to the payment origin from the previous step so the SDK download starts early:

// app/checkout/shipping/page.tsx
export default function ShippingStep() {
  return (
    <>
      <link rel="preconnect" href="https://js.payments-vendor.com" crossOrigin="" />
      <link rel="prefetch" href="/checkout/payment" as="document" />
      {/* … */}
    </>
  );
}

Common mistakes

Mistake Cost
Controlled inputs for a whole checkout form A render per keystroke
Validation on every keystroke INP cost + hostile UX
Missing autoComplete/inputMode Users type 20 fields by hand
Optimistic updates on money Trust destroyed when the total changes
Silent optimistic reverts Users don't know the action failed
Server Actions for rapid‑fire interactions Sequential round trips queue up
onClick handlers instead of form actions Nothing works before hydration
Payment SDK loaded on checkout step 1 140 KB for the 34% who never reach payment
Client validation without server validation 🚨 Security hole — actions are public endpoints

Lab 5.6 — Checkout and cart performance

  1. Disable JavaScript and attempt add‑to‑cart and checkout. Note everything that breaks. Convert those to <form action>.
  2. Profile typing in your checkout form: React Profiler while typing 10 characters. If you see 10 commits touching the whole form, convert to uncontrolled inputs.
  3. Audit autoComplete on every checkout field against the reference above.
  4. Make add‑to‑cart optimistic with useOptimistic. Verify the revert path shows a visible error by forcing a failure.
  5. Check the payment SDK's load point: is it in the bundle for checkout step 1? Move it to the payment route and preconnect from the previous step.
  6. Measure INP on checkout before and after, on mobile field data.

Checklist

  • Core flows work before hydration (<form action>)
  • Checkout inputs uncontrolled except where genuinely necessary
  • Validation on blur and submit, never per keystroke
  • Server‑side validation on every action, always
  • autoComplete + inputMode on every field
  • Optimistic UI for cart operations, never for money
  • Optimistic failures produce a visible, announced error
  • Rapid‑fire interactions debounced before hitting Server Actions
  • Payment SDK scoped to the payment step, with preconnect from the previous one

Next: Module 6 — Core Web Vitals playbooks