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
useOptimisticfor 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:
- 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.
- 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.
- 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.
- 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¶
- Disable JavaScript and attempt add‑to‑cart and checkout. Note everything that breaks.
Convert those to
<form action>. - 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.
- Audit
autoCompleteon every checkout field against the reference above. - Make add‑to‑cart optimistic with
useOptimistic. Verify the revert path shows a visible error by forcing a failure. - 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.
- 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+inputModeon 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