Optimistic create
Layer useOptimistic onto the list so a new invoice paints instantly and rolls back on failure.
A new invoice appears at the top of the /invoices list the instant you hit submit — before the server has answered — then settles into the real persisted row, or vanishes if the create fails.
You already have a create form that parses, validates, redirects, and works with JavaScript off. What it doesn’t do is feel instant: on a slow connection the user submits and waits out the round trip before the screen changes. This lesson closes that gap on the same-page list at /invoices, where useOptimistic paints a pending row the moment the form fires and reconciles it when the list revalidates.
This payoff is for the same-page list only. The standalone /invoices/new page has no list to prepend to, so its form keeps submitting and redirecting; the optimistic layer is something the list lends to the form, not part of the form itself.
Your mission
Section titled “Your mission”Layer useOptimistic onto the same-page list so a submitted invoice paints at the top of /invoices the instant the form fires, reconciles with the persisted row on success, and rolls back on failure.
You reach for useOptimistic so you write no rollback bookkeeping: it holds its update only for the lifetime of the surrounding transition, so the optimistic append and the action call fire inside one startTransition, and when the transition ends the list snaps back to whatever the server returned.
A few decisions make the reconcile clean. The form mints a UUIDv7 once at mount and posts it as the hidden id, so the optimistic row and the revalidated row share a key and React swaps one for the other in place. The optimistic row is a display subset: it carries the number, status, and total the user typed, with the customer name and due date as placeholders until the real row fills them in. The reducer stays pure, since React may re-run it while reconciling.
Optimism fits create-and-list, where the action almost always succeeds and the change is one small visible row. You deliberately leave edit non-optimistic, where the user is already looking at the form they changed.
/invoices paints a pending row at the top immediately, before the server responds.Coding time
Section titled “Coding time”Three changes turn the static list optimistic: wire useOptimistic into OptimisticInvoicesList, refactor NewInvoiceForm to read the appender from context and fire it in a transition, and add the _debug_fail branch to createInvoice. The base form and the action are already done. Try it before opening the reference.
useOptimistic and startTransition were covered in chapter 44, Forms the platform way.
Reference solution and walkthrough
The list owns the optimistic state
Section titled “The list owns the optimistic state”OptimisticInvoicesList is the Client Component wrapping the inline form and the rows. It holds the optimistic state and hands the appender to the form through context.
'use client';
import { Loader2 } from 'lucide-react';import Link from 'next/link';import { createContext, use, useOptimistic } from 'react';
import { NewInvoiceForm } from '@/app/invoices/new/new-invoice-form';import type { InvoiceListRow } from '@/lib/invoices/queries';import type { InvoiceStatus } from '@/lib/invoices/schema';
export type OptimisticInvoice = { id: string; number: string; status: InvoiceStatus; total: string; customerName: string; dueAt: Date | null; pending: true;};
export type ListItem = InvoiceListRow | OptimisticInvoice;
type AddOptimisticInvoiceContextValue = { addOptimistic: (invoice: OptimisticInvoice) => void; inline: boolean;};
const AddOptimisticInvoiceContext = createContext<AddOptimisticInvoiceContextValue>({ addOptimistic: () => {}, inline: false, });
export const useAddOptimisticInvoice = () => use(AddOptimisticInvoiceContext);
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD',});
const date = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric',});
type OptimisticInvoicesListProps = { initialInvoices: InvoiceListRow[]; customers: { id: string; name: string }[];};
export const OptimisticInvoicesList = ({ initialInvoices, customers,}: OptimisticInvoicesListProps) => { const [optimisticInvoices, addOptimistic] = useOptimistic< ListItem[], OptimisticInvoice >(initialInvoices, (current, next) => [next, ...current]);
return ( <AddOptimisticInvoiceContext value={{ addOptimistic, inline: true }}> <div className="flex flex-col gap-6"> <NewInvoiceForm customers={customers} />
{optimisticInvoices.length === 0 ? ( <p className="text-sm text-muted-foreground">No invoices yet.</p> ) : ( <ul data-testid="invoices-list" className="flex flex-col gap-2"> {optimisticInvoices.map((invoice) => 'pending' in invoice ? ( <li key={invoice.id}> <div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm opacity-60"> <span className="flex items-center gap-2 font-medium text-card-foreground"> <Loader2 className="size-4 animate-spin motion-reduce:animate-none" /> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customerName} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {invoice.dueAt ? date.format(invoice.dueAt) : '—'} </span> </div> </li> ) : ( <li key={invoice.id}> <Link href={`/invoices/${invoice.id}`} className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent" > <span className="font-medium text-card-foreground"> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customer.name} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {date.format(invoice.dueAt)} </span> </Link> </li> ), )} </ul> )} </div> </AddOptimisticInvoiceContext> );};useOptimistic takes the server truth (initialInvoices) and a reducer; addOptimistic queues an update that lives only as long as the transition wrapping it. That lifetime is the whole rollback story: when the action resolves and the list revalidates, the frame is discarded and the list snaps back to initialInvoices, which now holds the new row on success and not on failure. You write no undo logic.
'use client';
import { Loader2 } from 'lucide-react';import Link from 'next/link';import { createContext, use, useOptimistic } from 'react';
import { NewInvoiceForm } from '@/app/invoices/new/new-invoice-form';import type { InvoiceListRow } from '@/lib/invoices/queries';import type { InvoiceStatus } from '@/lib/invoices/schema';
export type OptimisticInvoice = { id: string; number: string; status: InvoiceStatus; total: string; customerName: string; dueAt: Date | null; pending: true;};
export type ListItem = InvoiceListRow | OptimisticInvoice;
type AddOptimisticInvoiceContextValue = { addOptimistic: (invoice: OptimisticInvoice) => void; inline: boolean;};
const AddOptimisticInvoiceContext = createContext<AddOptimisticInvoiceContextValue>({ addOptimistic: () => {}, inline: false, });
export const useAddOptimisticInvoice = () => use(AddOptimisticInvoiceContext);
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD',});
const date = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric',});
type OptimisticInvoicesListProps = { initialInvoices: InvoiceListRow[]; customers: { id: string; name: string }[];};
export const OptimisticInvoicesList = ({ initialInvoices, customers,}: OptimisticInvoicesListProps) => { const [optimisticInvoices, addOptimistic] = useOptimistic< ListItem[], OptimisticInvoice >(initialInvoices, (current, next) => [next, ...current]);
return ( <AddOptimisticInvoiceContext value={{ addOptimistic, inline: true }}> <div className="flex flex-col gap-6"> <NewInvoiceForm customers={customers} />
{optimisticInvoices.length === 0 ? ( <p className="text-sm text-muted-foreground">No invoices yet.</p> ) : ( <ul data-testid="invoices-list" className="flex flex-col gap-2"> {optimisticInvoices.map((invoice) => 'pending' in invoice ? ( <li key={invoice.id}> <div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm opacity-60"> <span className="flex items-center gap-2 font-medium text-card-foreground"> <Loader2 className="size-4 animate-spin motion-reduce:animate-none" /> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customerName} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {invoice.dueAt ? date.format(invoice.dueAt) : '—'} </span> </div> </li> ) : ( <li key={invoice.id}> <Link href={`/invoices/${invoice.id}`} className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent" > <span className="font-medium text-card-foreground"> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customer.name} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {date.format(invoice.dueAt)} </span> </Link> </li> ), )} </ul> )} </div> </AddOptimisticInvoiceContext> );};Prepend the new frame so the pending row paints at the top. Keep the reducer pure: React may re-run it during reconciliation.
'use client';
import { Loader2 } from 'lucide-react';import Link from 'next/link';import { createContext, use, useOptimistic } from 'react';
import { NewInvoiceForm } from '@/app/invoices/new/new-invoice-form';import type { InvoiceListRow } from '@/lib/invoices/queries';import type { InvoiceStatus } from '@/lib/invoices/schema';
export type OptimisticInvoice = { id: string; number: string; status: InvoiceStatus; total: string; customerName: string; dueAt: Date | null; pending: true;};
export type ListItem = InvoiceListRow | OptimisticInvoice;
type AddOptimisticInvoiceContextValue = { addOptimistic: (invoice: OptimisticInvoice) => void; inline: boolean;};
const AddOptimisticInvoiceContext = createContext<AddOptimisticInvoiceContextValue>({ addOptimistic: () => {}, inline: false, });
export const useAddOptimisticInvoice = () => use(AddOptimisticInvoiceContext);
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD',});
const date = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric',});
type OptimisticInvoicesListProps = { initialInvoices: InvoiceListRow[]; customers: { id: string; name: string }[];};
export const OptimisticInvoicesList = ({ initialInvoices, customers,}: OptimisticInvoicesListProps) => { const [optimisticInvoices, addOptimistic] = useOptimistic< ListItem[], OptimisticInvoice >(initialInvoices, (current, next) => [next, ...current]);
return ( <AddOptimisticInvoiceContext value={{ addOptimistic, inline: true }}> <div className="flex flex-col gap-6"> <NewInvoiceForm customers={customers} />
{optimisticInvoices.length === 0 ? ( <p className="text-sm text-muted-foreground">No invoices yet.</p> ) : ( <ul data-testid="invoices-list" className="flex flex-col gap-2"> {optimisticInvoices.map((invoice) => 'pending' in invoice ? ( <li key={invoice.id}> <div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm opacity-60"> <span className="flex items-center gap-2 font-medium text-card-foreground"> <Loader2 className="size-4 animate-spin motion-reduce:animate-none" /> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customerName} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {invoice.dueAt ? date.format(invoice.dueAt) : '—'} </span> </div> </li> ) : ( <li key={invoice.id}> <Link href={`/invoices/${invoice.id}`} className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent" > <span className="font-medium text-card-foreground"> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customer.name} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {date.format(invoice.dueAt)} </span> </Link> </li> ), )} </ul> )} </div> </AddOptimisticInvoiceContext> );};Share the appender and flag inline: true. The default { addOptimistic: () => {}, inline: false } is a safe no-op, and inline: false is the signal the standalone /invoices/new form reads to skip the optimistic path for plain submit-and-redirect.
'use client';
import { Loader2 } from 'lucide-react';import Link from 'next/link';import { createContext, use, useOptimistic } from 'react';
import { NewInvoiceForm } from '@/app/invoices/new/new-invoice-form';import type { InvoiceListRow } from '@/lib/invoices/queries';import type { InvoiceStatus } from '@/lib/invoices/schema';
export type OptimisticInvoice = { id: string; number: string; status: InvoiceStatus; total: string; customerName: string; dueAt: Date | null; pending: true;};
export type ListItem = InvoiceListRow | OptimisticInvoice;
type AddOptimisticInvoiceContextValue = { addOptimistic: (invoice: OptimisticInvoice) => void; inline: boolean;};
const AddOptimisticInvoiceContext = createContext<AddOptimisticInvoiceContextValue>({ addOptimistic: () => {}, inline: false, });
export const useAddOptimisticInvoice = () => use(AddOptimisticInvoiceContext);
const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD',});
const date = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric',});
type OptimisticInvoicesListProps = { initialInvoices: InvoiceListRow[]; customers: { id: string; name: string }[];};
export const OptimisticInvoicesList = ({ initialInvoices, customers,}: OptimisticInvoicesListProps) => { const [optimisticInvoices, addOptimistic] = useOptimistic< ListItem[], OptimisticInvoice >(initialInvoices, (current, next) => [next, ...current]);
return ( <AddOptimisticInvoiceContext value={{ addOptimistic, inline: true }}> <div className="flex flex-col gap-6"> <NewInvoiceForm customers={customers} />
{optimisticInvoices.length === 0 ? ( <p className="text-sm text-muted-foreground">No invoices yet.</p> ) : ( <ul data-testid="invoices-list" className="flex flex-col gap-2"> {optimisticInvoices.map((invoice) => 'pending' in invoice ? ( <li key={invoice.id}> <div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm opacity-60"> <span className="flex items-center gap-2 font-medium text-card-foreground"> <Loader2 className="size-4 animate-spin motion-reduce:animate-none" /> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customerName} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {invoice.dueAt ? date.format(invoice.dueAt) : '—'} </span> </div> </li> ) : ( <li key={invoice.id}> <Link href={`/invoices/${invoice.id}`} className="flex items-center justify-between gap-4 rounded-lg border border-border bg-card p-3 text-sm hover:bg-accent" > <span className="font-medium text-card-foreground"> {invoice.number} </span> <span className="text-muted-foreground"> {invoice.customer.name} </span> <span className="text-muted-foreground"> {invoice.status} </span> <span className="tabular-nums text-card-foreground"> {currency.format(Number(invoice.total))} </span> <span className="text-muted-foreground"> {date.format(invoice.dueAt)} </span> </Link> </li> ), )} </ul> )} </div> </AddOptimisticInvoiceContext> );};One render, two row shapes. A pending frame renders dimmed with a spinner; a settled row renders the Link. Keying both by id is what lets the revalidated row replace its optimistic twin in place.
The form mints the reconcile key and fires the transition
Section titled “The form mints the reconcile key and fires the transition”NewInvoiceForm is the create-lesson component with four additions: it reads the appender and inline flag from context, mints the reconcile id at mount, posts it as a hidden input, and when inline wraps the submit so the optimistic append and the action fire together.
'use client';
import { startTransition, useActionState, useState } from 'react';import { uuidv7 } from 'uuidv7';
import { useAddOptimisticInvoice } from '@/app/invoices/_components/optimistic-invoices-list';import { createInvoice } from '@/lib/invoices/actions';import { type InvoiceStatus, statusSchema } from '@/lib/invoices/schema';// FieldError, SubmitButton, Input, Label, NativeSelect imports unchanged
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => { const [state, formAction] = useActionState(createInvoice, null); const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const { addOptimistic, inline } = useAddOptimisticInvoice(); const [tempId] = useState(() => uuidv7());
const [defaults, setDefaults] = useState(initialDefaults); const [submitCount, setSubmitCount] = useState(0);
// Echo the typed values back as the next defaultValue set and bump the // remount key, so a failed submit keeps what the user entered. const echoSubmittedValues = (formData: FormData) => { setDefaults(/* ...echoedFields → String(formData.get(field)) */); setSubmitCount((count) => count + 1); };
// Inline on /invoices: fire the optimistic append and the action in one // transition so the pending row paints before the server responds. const handleSubmit = (formData: FormData) => { startTransition(() => { echoSubmittedValues(formData); addOptimistic({ id: tempId, number: String(formData.get('number') ?? ''), status: (formData.get('status') as InvoiceStatus) ?? 'draft', total: String(formData.get('total') ?? ''), customerName: '—', dueAt: null, pending: true, }); formAction(formData); }); };
return ( <section className="flex flex-col gap-4"> {inline && <h2 className="text-lg font-semibold">New invoice</h2>} <form key={submitCount} action={inline ? handleSubmit : formAction} onSubmit={ inline ? undefined : (event) => echoSubmittedValues(new FormData(event.currentTarget)) } data-testid="new-invoice-form" className="flex flex-col gap-4" > <input type="hidden" name="id" defaultValue={tempId} /> {/* customerId, number, status, total, issuedAt, dueAt, currency fields */}
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <input type="checkbox" name="_debug_fail" value="1" /> Simulate failure </label>
{state?.ok === false && state.error.code !== 'validation' && ( <p role="alert" className="text-destructive"> {state.error.userMessage} </p> )}
<SubmitButton>Create invoice</SubmitButton> </form> </section> );};Read the shared appender and the inline flag. Outside a provider these are the no-op default and false.
'use client';
import { startTransition, useActionState, useState } from 'react';import { uuidv7 } from 'uuidv7';
import { useAddOptimisticInvoice } from '@/app/invoices/_components/optimistic-invoices-list';import { createInvoice } from '@/lib/invoices/actions';import { type InvoiceStatus, statusSchema } from '@/lib/invoices/schema';// FieldError, SubmitButton, Input, Label, NativeSelect imports unchanged
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => { const [state, formAction] = useActionState(createInvoice, null); const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const { addOptimistic, inline } = useAddOptimisticInvoice(); const [tempId] = useState(() => uuidv7());
const [defaults, setDefaults] = useState(initialDefaults); const [submitCount, setSubmitCount] = useState(0);
// Echo the typed values back as the next defaultValue set and bump the // remount key, so a failed submit keeps what the user entered. const echoSubmittedValues = (formData: FormData) => { setDefaults(/* ...echoedFields → String(formData.get(field)) */); setSubmitCount((count) => count + 1); };
// Inline on /invoices: fire the optimistic append and the action in one // transition so the pending row paints before the server responds. const handleSubmit = (formData: FormData) => { startTransition(() => { echoSubmittedValues(formData); addOptimistic({ id: tempId, number: String(formData.get('number') ?? ''), status: (formData.get('status') as InvoiceStatus) ?? 'draft', total: String(formData.get('total') ?? ''), customerName: '—', dueAt: null, pending: true, }); formAction(formData); }); };
return ( <section className="flex flex-col gap-4"> {inline && <h2 className="text-lg font-semibold">New invoice</h2>} <form key={submitCount} action={inline ? handleSubmit : formAction} onSubmit={ inline ? undefined : (event) => echoSubmittedValues(new FormData(event.currentTarget)) } data-testid="new-invoice-form" className="flex flex-col gap-4" > <input type="hidden" name="id" defaultValue={tempId} /> {/* customerId, number, status, total, issuedAt, dueAt, currency fields */}
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <input type="checkbox" name="_debug_fail" value="1" /> Simulate failure </label>
{state?.ok === false && state.error.code !== 'validation' && ( <p role="alert" className="text-destructive"> {state.error.userMessage} </p> )}
<SubmitButton>Create invoice</SubmitButton> </form> </section> );};Mint a UUIDv7 once. The lazy initializer runs a single time per form instance, so the id stays stable across re-renders to serve as the shared key. Call uuidv7() in the render body instead and a fresh id each re-render shifts the key under the optimistic row, breaking the swap.
'use client';
import { startTransition, useActionState, useState } from 'react';import { uuidv7 } from 'uuidv7';
import { useAddOptimisticInvoice } from '@/app/invoices/_components/optimistic-invoices-list';import { createInvoice } from '@/lib/invoices/actions';import { type InvoiceStatus, statusSchema } from '@/lib/invoices/schema';// FieldError, SubmitButton, Input, Label, NativeSelect imports unchanged
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => { const [state, formAction] = useActionState(createInvoice, null); const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const { addOptimistic, inline } = useAddOptimisticInvoice(); const [tempId] = useState(() => uuidv7());
const [defaults, setDefaults] = useState(initialDefaults); const [submitCount, setSubmitCount] = useState(0);
// Echo the typed values back as the next defaultValue set and bump the // remount key, so a failed submit keeps what the user entered. const echoSubmittedValues = (formData: FormData) => { setDefaults(/* ...echoedFields → String(formData.get(field)) */); setSubmitCount((count) => count + 1); };
// Inline on /invoices: fire the optimistic append and the action in one // transition so the pending row paints before the server responds. const handleSubmit = (formData: FormData) => { startTransition(() => { echoSubmittedValues(formData); addOptimistic({ id: tempId, number: String(formData.get('number') ?? ''), status: (formData.get('status') as InvoiceStatus) ?? 'draft', total: String(formData.get('total') ?? ''), customerName: '—', dueAt: null, pending: true, }); formAction(formData); }); };
return ( <section className="flex flex-col gap-4"> {inline && <h2 className="text-lg font-semibold">New invoice</h2>} <form key={submitCount} action={inline ? handleSubmit : formAction} onSubmit={ inline ? undefined : (event) => echoSubmittedValues(new FormData(event.currentTarget)) } data-testid="new-invoice-form" className="flex flex-col gap-4" > <input type="hidden" name="id" defaultValue={tempId} /> {/* customerId, number, status, total, issuedAt, dueAt, currency fields */}
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <input type="checkbox" name="_debug_fail" value="1" /> Simulate failure </label>
{state?.ok === false && state.error.code !== 'validation' && ( <p role="alert" className="text-destructive"> {state.error.userMessage} </p> )}
<SubmitButton>Create invoice</SubmitButton> </form> </section> );};Post that id with the form. The action threads it into the insert, so the revalidated row carries this exact id.
'use client';
import { startTransition, useActionState, useState } from 'react';import { uuidv7 } from 'uuidv7';
import { useAddOptimisticInvoice } from '@/app/invoices/_components/optimistic-invoices-list';import { createInvoice } from '@/lib/invoices/actions';import { type InvoiceStatus, statusSchema } from '@/lib/invoices/schema';// FieldError, SubmitButton, Input, Label, NativeSelect imports unchanged
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => { const [state, formAction] = useActionState(createInvoice, null); const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const { addOptimistic, inline } = useAddOptimisticInvoice(); const [tempId] = useState(() => uuidv7());
const [defaults, setDefaults] = useState(initialDefaults); const [submitCount, setSubmitCount] = useState(0);
// Echo the typed values back as the next defaultValue set and bump the // remount key, so a failed submit keeps what the user entered. const echoSubmittedValues = (formData: FormData) => { setDefaults(/* ...echoedFields → String(formData.get(field)) */); setSubmitCount((count) => count + 1); };
// Inline on /invoices: fire the optimistic append and the action in one // transition so the pending row paints before the server responds. const handleSubmit = (formData: FormData) => { startTransition(() => { echoSubmittedValues(formData); addOptimistic({ id: tempId, number: String(formData.get('number') ?? ''), status: (formData.get('status') as InvoiceStatus) ?? 'draft', total: String(formData.get('total') ?? ''), customerName: '—', dueAt: null, pending: true, }); formAction(formData); }); };
return ( <section className="flex flex-col gap-4"> {inline && <h2 className="text-lg font-semibold">New invoice</h2>} <form key={submitCount} action={inline ? handleSubmit : formAction} onSubmit={ inline ? undefined : (event) => echoSubmittedValues(new FormData(event.currentTarget)) } data-testid="new-invoice-form" className="flex flex-col gap-4" > <input type="hidden" name="id" defaultValue={tempId} /> {/* customerId, number, status, total, issuedAt, dueAt, currency fields */}
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <input type="checkbox" name="_debug_fail" value="1" /> Simulate failure </label>
{state?.ok === false && state.error.code !== 'validation' && ( <p role="alert" className="text-destructive"> {state.error.userMessage} </p> )}
<SubmitButton>Create invoice</SubmitButton> </form> </section> );};Echo the typed values, append the optimistic frame, then call formAction, all in one transition. They must share one transition: split them and the optimistic update ends the moment the first settles, before the server answers.
'use client';
import { startTransition, useActionState, useState } from 'react';import { uuidv7 } from 'uuidv7';
import { useAddOptimisticInvoice } from '@/app/invoices/_components/optimistic-invoices-list';import { createInvoice } from '@/lib/invoices/actions';import { type InvoiceStatus, statusSchema } from '@/lib/invoices/schema';// FieldError, SubmitButton, Input, Label, NativeSelect imports unchanged
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => { const [state, formAction] = useActionState(createInvoice, null); const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const { addOptimistic, inline } = useAddOptimisticInvoice(); const [tempId] = useState(() => uuidv7());
const [defaults, setDefaults] = useState(initialDefaults); const [submitCount, setSubmitCount] = useState(0);
// Echo the typed values back as the next defaultValue set and bump the // remount key, so a failed submit keeps what the user entered. const echoSubmittedValues = (formData: FormData) => { setDefaults(/* ...echoedFields → String(formData.get(field)) */); setSubmitCount((count) => count + 1); };
// Inline on /invoices: fire the optimistic append and the action in one // transition so the pending row paints before the server responds. const handleSubmit = (formData: FormData) => { startTransition(() => { echoSubmittedValues(formData); addOptimistic({ id: tempId, number: String(formData.get('number') ?? ''), status: (formData.get('status') as InvoiceStatus) ?? 'draft', total: String(formData.get('total') ?? ''), customerName: '—', dueAt: null, pending: true, }); formAction(formData); }); };
return ( <section className="flex flex-col gap-4"> {inline && <h2 className="text-lg font-semibold">New invoice</h2>} <form key={submitCount} action={inline ? handleSubmit : formAction} onSubmit={ inline ? undefined : (event) => echoSubmittedValues(new FormData(event.currentTarget)) } data-testid="new-invoice-form" className="flex flex-col gap-4" > <input type="hidden" name="id" defaultValue={tempId} /> {/* customerId, number, status, total, issuedAt, dueAt, currency fields */}
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <input type="checkbox" name="_debug_fail" value="1" /> Simulate failure </label>
{state?.ok === false && state.error.code !== 'validation' && ( <p role="alert" className="text-destructive"> {state.error.userMessage} </p> )}
<SubmitButton>Create invoice</SubmitButton> </form> </section> );};The branch. Inline runs the wrapped handleSubmit; standalone binds formAction straight to action so React emits the no-JS POST target, leaving progressive enhancement untouched.
'use client';
import { startTransition, useActionState, useState } from 'react';import { uuidv7 } from 'uuidv7';
import { useAddOptimisticInvoice } from '@/app/invoices/_components/optimistic-invoices-list';import { createInvoice } from '@/lib/invoices/actions';import { type InvoiceStatus, statusSchema } from '@/lib/invoices/schema';// FieldError, SubmitButton, Input, Label, NativeSelect imports unchanged
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => { const [state, formAction] = useActionState(createInvoice, null); const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const { addOptimistic, inline } = useAddOptimisticInvoice(); const [tempId] = useState(() => uuidv7());
const [defaults, setDefaults] = useState(initialDefaults); const [submitCount, setSubmitCount] = useState(0);
// Echo the typed values back as the next defaultValue set and bump the // remount key, so a failed submit keeps what the user entered. const echoSubmittedValues = (formData: FormData) => { setDefaults(/* ...echoedFields → String(formData.get(field)) */); setSubmitCount((count) => count + 1); };
// Inline on /invoices: fire the optimistic append and the action in one // transition so the pending row paints before the server responds. const handleSubmit = (formData: FormData) => { startTransition(() => { echoSubmittedValues(formData); addOptimistic({ id: tempId, number: String(formData.get('number') ?? ''), status: (formData.get('status') as InvoiceStatus) ?? 'draft', total: String(formData.get('total') ?? ''), customerName: '—', dueAt: null, pending: true, }); formAction(formData); }); };
return ( <section className="flex flex-col gap-4"> {inline && <h2 className="text-lg font-semibold">New invoice</h2>} <form key={submitCount} action={inline ? handleSubmit : formAction} onSubmit={ inline ? undefined : (event) => echoSubmittedValues(new FormData(event.currentTarget)) } data-testid="new-invoice-form" className="flex flex-col gap-4" > <input type="hidden" name="id" defaultValue={tempId} /> {/* customerId, number, status, total, issuedAt, dueAt, currency fields */}
<label className="flex items-center gap-2 text-sm text-muted-foreground"> <input type="checkbox" name="_debug_fail" value="1" /> Simulate failure </label>
{state?.ok === false && state.error.code !== 'validation' && ( <p role="alert" className="text-destructive"> {state.error.userMessage} </p> )}
<SubmitButton>Create invoice</SubmitButton> </form> </section> );};The failure switch: chapter-local scaffolding to force a failure and watch the rollback, not a production control.
Value retention after a failure needs nothing new; it is create-lesson machinery. Under React 19 a <form action={fn}> resets its uncontrolled inputs on every commit, so echoing the submitted values as the next defaultValue and remounting via key={submitCount} re-applies them. That is why a forced failure keeps your typed values on screen.
The failure switch in the action
Section titled “The failure switch in the action”To watch the rollback by hand you need the create to fail on demand. The action grows one guarded branch, after the parse and before the insert.
const parsed = createInvoiceInputSchema.safeParse( Object.fromEntries(formData), ); if (!parsed.success) { return err( 'validation', 'Check the highlighted fields.', z.flattenError(parsed.error).fieldErrors, ); }
// remove before production — teaching aid only if (formData.get('_debug_fail') === '1') { await new Promise((resolve) => setTimeout(resolve, 500)); return err('internal', 'Forced failure for verify'); }
const { organizationId, userId } = await getActiveContext();Position carries the logic. After the parse, a forced failure still produces a real Result; before the insert, it persists nothing, so there is no real row for the optimistic frame to reconcile into. The 500 ms sleep holds the pending row on screen long enough to watch it roll back. It returns err('internal', …) rather than a validation error so the form shows a form-level banner, the code the banner branch reads.
The insert is unchanged: it spreads parsed.data, so the client-generated id flows straight through; when none is posted the column’s $defaultFn mints one server-side. The optimistic path just knows the id in advance.
The hook's signature, the reducer form, and the delete-with-error-recovery example that mirrors this rollback.
Why the optimistic append and the action share one transition — its lifetime is your rollback.
The package the form calls to mint the time-sortable reconcile id both rows share.
Moment of truth
Section titled “Moment of truth”With the database up, migrated, and seeded (docker compose up -d, pnpm db:migrate, pnpm db:seed), run the suite:
pnpm test:lesson 5It checks observable behavior, not your file or symbol names: the inline form posts a real UUIDv7 as its id, the list renders each row once as a detail link keyed by id, and _debug_fail makes createInvoice return an internal Result after the delay. A green run:
✓ tests/lessons/Lesson 5.test.ts (5 tests) 1240ms
Test Files 1 passed (1) Tests 5 passed (5)The instant paint, flicker-free swap, value retention, and non-optimistic edit are interaction behaviors no Node test can see, so confirm them by hand on /invoices:
/invoices, the same row is there, persisted, with no duplicate.Next, delete gets the same care: a Drizzle transaction so an invoice and its lines commit or roll back as one.