Skip to content
Chapter 47Lesson 5

Optimistic create

A new invoice appears at the top of the list the instant you hit submit — before the server has answered — then quietly settles into the real persisted row, or vanishes if the create fails.

You already have the create form from earlier: it parses, it validates, it redirects, it works with JavaScript off. What it does not do is feel instant. On a slow connection the user clicks Create invoice, watches the spinner, and waits for the round trip before anything changes on screen. This lesson closes that gap on the same-page list at /invoices. The moment the form fires, a pending row paints at the top — dimmed, with a spinner — built from the values the user just typed. When the server commits the row and the list revalidates, that pending row reconciles into the real one without a flicker. When the action comes back with ok: false, the pending row disappears and a banner explains why.

The optimistic frame mid-flight — `useOptimistic` paints the pending row before the server has answered.

Notice the asymmetry before you start. This payoff is for the same-page list only. The standalone /invoices/new page has no list to optimistically prepend to, so there the form keeps doing exactly what it did: submit and redirect. The optimistic layer is something the list lends to the form, not something baked into the form itself.

Layer useOptimistic onto the same-page list so a submitted invoice paints at the top of /invoices the moment the form fires, then reconciles with the persisted row on success and rolls back on failure. The whole point of reaching for useOptimistic here — instead of hand-rolling a “pending rows” state and a try/catch that yanks them back out — is that you write no rollback bookkeeping at all. useOptimistic holds its update only for the lifetime of the surrounding transition; when that transition ends, the update is gone and the list is back to whatever the server says. That single property is why the optimistic append and the action call have to fire inside one startTransition: the transition’s lifetime is the rollback. There is no undo to write, and writing one would fight the hook.

The reconcile leans on a real id generated on the client. The form mints a UUIDv7 once at mount, posts it as the hidden id input, and the action threads it into the insert. So the optimistic row and the revalidated row carry the same id — and because the list keys rows by id, React swaps one for the other in place, with no flash and no duplicate. A throwaway temp string ("temp-1") would not match the persisted row’s key, so the swap would flicker the optimistic row out and the real one in. The create schema has accepted an optional id since you built the form, so nothing changes on the schema side this lesson. The optimistic frame is a display subset, not the full joined row — you have the number, status and total the user typed, but the customer name and due date are placeholders the revalidated row fills in. Keep the useOptimistic reducer pure; React may run it several times while reconciling, so a console.log or any side effect inside it will misbehave.

This is also a lesson about when optimism earns its place, and the honest answer is: not everywhere. The create-and-list shape is the sweet spot — the action almost always succeeds, the change is visible, and the UI delta is one small row. So you ship it here and you deliberately do not extend it to edit, where the user is already staring at the form they changed and the instant-feedback case is weak. Optimism is a tool with a trigger, and edit doesn’t pull it. The transaction and the success toast are next lesson’s work — leave them be.

Submitting a valid invoice through the inline form on /invoices paints a pending row at the top immediately, before the server responds.
tested
On success the pending row becomes the persisted row with no flicker and no duplicate — they reconcile by their shared id.
tested
On a forced failure the optimistic row disappears and a banner shows the action’s message.
tested
After a forced failure the form keeps the values that were typed — nothing is blanked.
untested
Editing an invoice triggers no optimistic row.
untested

Implement the optimistic layer against the brief and the tests: wire useOptimistic into OptimisticInvoicesList, refactor NewInvoiceForm to read the appender from context and fire it inside a transition, and add the _debug_fail branch to createInvoice so you can watch the rollback by hand. The base form and the action are already done — focus only on what the optimistic layer adds. Attempt it before opening the reference below.

The mechanics of useOptimistic and startTransition themselves were covered in chapter 44, Forms the platform way — this walkthrough leans on them rather than re-deriving them.

Reference solution and walkthrough

OptimisticInvoicesList is the Client Component that wraps the inline form and the rows. It holds the optimistic state and hands the appender down 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 the caller wraps it in.

'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>
);
};

The reducer prepends the new frame so the pending row paints at the top. Pure on purpose: React may re-run it during reconciliation, so no logging, no side effects.

'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>
);
};

The provider shares the appender with the inline form and flags inline: true. The context default is { addOptimistic: () => {}, inline: false } — a safe no-op, which is exactly why the standalone form at /invoices/new skips the optimistic path.

'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 (opacity-60) with a spinner; a settled row renders the Link keyed by its real id. Keying by id is what lets the revalidated row replace its optimistic twin in place.

1 / 1

The reducer is the whole rollback story. useOptimistic keeps the appended frame only while the transition the form opens is alive. When the action returns and the list revalidates, the transition ends, the optimistic frame is discarded, and the list snaps back to the server’s initialInvoices — which on success now contains the new row, and on failure does not. You never wrote a line of undo logic; the absence of bookkeeping is the feature.

The context default deserves a second look. It is { addOptimistic: () => {}, inline: false } — not just to avoid a crash when there is no provider, but because inline: false is the signal the standalone form reads to take the plain submit-and-redirect path. The same form component behaves differently in the two places it renders, and this one flag is what routes it.

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 same component from the create lesson. The optimistic refactor adds four things: it reads the appender and the inline flag from context, it generates the reconcile id at mount, it posts that id as a hidden input, and — when inline — it 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 real UUIDv7 once, at mount. The lazy initializer runs a single time per form instance, so the id is stable across re-renders — it is the key both the optimistic frame and the persisted row will share.

'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 row that comes back from revalidation 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>
);
};

The inline submit. Echo the typed values, append the optimistic frame, then call formAction — all inside one transition. The optimistic frame holds for the transition’s lifetime, which is the automatic rollback.

'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, the wrapped handleSubmit runs (optimistic path). Standalone, the bound formAction is passed straight to action so React emits the no-JS POST target — 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 so you can force the action to fail and watch the rollback; it is not a production control.

1 / 1

A few decisions are worth naming.

Why useState(() => uuidv7()) and not a fresh uuidv7() in the body. The lazy initializer runs once, the first time the component mounts. Call uuidv7() directly in the render body and you mint a new id on every re-render — the key would change underneath the optimistic row and the swap would break. The id has to be stable for the lifetime of the form instance, which is exactly what the lazy useState gives you.

Why one startTransition around both calls. addOptimistic and formAction share the transition because that is the only way the optimistic frame survives until the action resolves. Open two separate transitions and the optimistic update would end the moment the first one settled, before the server answered. One transition, two effects: paint the pending row, run the action.

Why the standalone path passes formAction straight to action. When inline is false, the form hands the bound server action directly to the action prop. That is what makes React emit the hidden action-reference field during SSR, so the form has a real POST target with JavaScript disabled. Wrap it in handleSubmit there and you would lose progressive enhancement for no benefit, since there is no list to prepend to. The onSubmit on that path only echoes the typed values; it never runs on the no-JS POST.

The value-retention requirement (no blanking after a failure) is already handled by machinery from the create lesson, not anything new here. A <form action={fn}> resets its uncontrolled inputs on every commit under React 19 — including a failed one — so the form echoes the submitted values back as the next defaultValue set and remounts the field cluster via key={submitCount} to re-apply them. That is why a forced failure leaves your typed values on screen. If that remount-on-submit pattern is unfamiliar, it is the same one from chapter 44, Forms the platform way.

To verify the rollback by hand you need a way to make the create fail on demand. The action grows one guarded branch, placed after the parse and before the insert.

src/lib/invoices/actions.ts
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 is everything. The guard sits after the parse so a forced failure still produces a real Result, and before the insert so it persists nothing — which is the whole point, since there must be no real row for the optimistic frame to reconcile into. The deliberate 500 ms sleep holds the pending row on screen long enough to actually watch it roll back; without it the failure would return so fast you’d see nothing. And it returns err('internal', …) rather than a validation error so the form surfaces it as a form-level banner, not a field message — internal is the code the banner branch already reads.

The insert itself is unchanged. It already spreads parsed.data into the values, so the client-generated id flows straight through to the row; when no id is posted (it is optional in the schema), the column’s $defaultFn mints one server-side instead. Both paths produce a valid row — the optimistic path just happens to know the id in advance.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 5

The suite needs the database up, migrated and seeded first (docker compose up -d, pnpm db:migrate, pnpm db:seed). It drives the observable result, not your file or symbol names: it reads the reconcile key the inline form posts (a real UUIDv7, freshly minted per instance — a throwaway temp string fails), confirms the list renders each existing row exactly once as a detail link keyed by its id (the shape a revalidated row reconciles into instead of duplicating), and confirms _debug_fail makes createInvoice return an internal Result after the delay while persisting nothing. A green run looks like this:

✓ tests/lessons/Lesson 5.test.ts (5 tests) 1240ms
Test Files 1 passed (1)
Tests 5 passed (5)

The instant paint, the flicker-free swap, value retention, and edit staying non-optimistic are DOM-and-interaction behaviors no Node test can see. Confirm those by hand on /invoices:

Submit a valid invoice — the row appears at the top instantly, then the detail page renders. Navigate back to /invoices and the same row is already there, persisted, with no duplicate.
untested
Tick Simulate failure and submit — the pending row paints, then ~500 ms later vanishes, the banner reads “Forced failure for verify”, and the form still holds the values you typed.
untested
Open an invoice’s edit form and save — no pending row appears anywhere.
untested

With create now feeling instant, the next lesson tightens delete: wrapping it in a Drizzle transaction so the invoice and its lines commit or roll back as one, and returning to the list with a success banner the moment it’s done.