Skip to content
Chapter 79Lesson 4

Submit, reset, and guard

Completing all four steps and clicking Create customer writes the customer and lands you on its detail page.

Step 4 reads back the contact, billing, and preferences from the earlier steps, with one button below. Click it and a single Server-Action POST writes the customer row plus a customer.created audit entry, then the router pushes you to the chapter 062 detail page at /customers/[newId], wizard reset behind you, so the next new customer starts blank. Arm a forced failure and the same click shows an inline error under the button with every field left as you typed it, ready to retry. Double-click, and the action fires once.

The submit button is the seam between the client store and the server-owned write, the single place they meet. The store owns the draft in memory, the action owns the write, and neither imports the other, which keeps both replaceable. Everything you built so far — four slices holding the draft, atomic selectors feeding the forms, a Next-gate deriving validity client-side — stays clear of the database. The button is where it finally writes.

That seam re-parses. The button hands the action the composite draft from the store, and the action runs it back through createCustomerInput before writing anything. The client Next-gate only greys out Next so a user cannot advance with an empty field; it is trivially bypassable, and a Server Action is a public HTTP endpoint anyone can POST to. The re-parse at the action is the correctness boundary; the gate is not. You wire the action with authedInputAction, the direct-object sibling of the form-bound wrapper, so the button calls it as await createCustomer({ contact, billing, preferences }) — a plain object, no FormData, since the data already lives parsed in the store.

Tenancy lives entirely server-side. authedInputAction resolves the active session at the boundary, and the orgId it carries flows into both the write and the audit entry. The store has no orgId field because the server decides whose customer this is: a client could lie about its draft, but it cannot lie about an org it never names.

Two decisions on the button carry the lesson. First, where the transient error goes: a failed submit — a network blip, a forced failure, a duplicate email — sets a message in the button’s own useState, not the store, because the store holds the draft the user owns and a momentary failure is not part of that draft. Second, reset fires on success only. The natural-looking if (!result.ok) { reset(); ... } wipes everything the user typed the instant the server hiccups, so reset lives on the success branch instead, and it runs before the redirect — order you hold as a discipline, since on surfaces that stay mounted across the reset (a cart in a header) it matters.

You reach for two new tools. useTransition gives you isPending, which does double duty: it drives the “Creating…” label and guards the double-submit, since the first click disables the button while the transition runs — one hook for both, which is why it beats a plain useState<boolean>. useShallow belongs on both composite reads, the review’s pick of three slices and the button’s identical payload, because each assembles a fresh literal every render that the default Object.is check would read as a change. The rule: a selector returning a fresh object or array wants useShallow; one returning a primitive or an existing reference stays on the default check. On the server you reuse authedInputAction, the canonical Result, logAudit, and the in-memory pushCustomer, unchanged.

Two things stay out of scope. Do not stash the new customer’s id in the store — it is server state the redirect transitions to. And do not add idempotency keys: one user and one transition is naturally idempotent here, while processed_events is for external retries and lands with billing webhooks in a later chapter.

Completing all four steps with valid data and submitting creates the customer and returns { ok: true, data: { id } }, and writes exactly one customer.created audit row in the active org.
tested
A programmatic submit with a malformed composite payload returns { ok: false, error: { code: 'validation' } } and writes no audit row.
tested
Submitting a customer whose email duplicates a seeded one (dupe@acme.test) returns { ok: false, error: { code: 'conflict' } } and leaves the audit log unchanged.
tested
On success the router lands on /customers/[newId] and the customer detail page renders.
untested
After a successful submit, navigating back to step 1 shows the wizard reset to its initial state — empty slices, currentStep: 1, completedSteps empty.
untested
With force-failure armed, submitting shows an inline error under the button and leaves every field populated for retry.
untested
A double-click (or “Force double-submit”) fires the action only once — one POST, one audit row.
untested
The review step and the submit button each read the three data slices through a useShallow pick, and useShallow appears in those two files only.
untested

Write createCustomer in actions.ts, the useShallow review in step-4/page.tsx, and the guarded submit button in step-4/submit-button.tsx, against the brief above and the tests. Try it before opening the reference.

Reference solution and walkthrough

_lib/wizard/actions.ts is the server half of the seam: one action plus a helper that recognizes a duplicate-email throw.

'use server';
import { revalidatePath } from 'next/cache';
import { createCustomerInput } from '@/app/(app)/customers/new/_lib/wizard/schemas';
import { logAudit } from '@/lib/audit-log';
import { authedInputAction } from '@/lib/authed-action';
import { consumeForceFailure } from '@/lib/force-failure';
import { conflict, err, ok } from '@/lib/result';
import { pushCustomer } from '@/server/store';
import type { Customer } from '@/server/types';
const isUniqueViolation = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: unknown }).code === '23505';
const FORCE_FAILURE_DELAY_MS = 200;
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const createCustomer = authedInputAction(
'member',
createCustomerInput,
async (input, ctx) => {
if (consumeForceFailure(ctx.userId)) {
await delay(FORCE_FAILURE_DELAY_MS);
return err('internal', 'Forced action failure for verification');
}
let row: Customer;
try {
row = pushCustomer({
orgId: ctx.orgId,
firstName: input.contact.firstName,
lastName: input.contact.lastName,
email: input.contact.email,
phone: input.contact.phone,
...input.billing,
defaultCurrency: input.preferences.defaultCurrency,
language: input.preferences.language,
notificationChannels: input.preferences.channels,
});
} catch (error) {
if (isUniqueViolation(error)) {
return conflict(
'A customer with this email already exists in this organization.',
null,
);
}
throw error;
}
logAudit({
orgId: ctx.orgId,
actorUserId: ctx.userId,
action: 'customer.created',
subjectId: row.id,
});
revalidatePath('/customers');
return ok({ id: row.id });
},
);

The direct-object action: first arg the minimum role, second the schema it re-parses the input against, and the body receives the parsed input plus a ctx carrying the resolved session (userId, orgId, role). The button calls it straight — await createCustomer({ contact, billing, preferences }) — no FormData, no _prev, because the data is already a plain object.

'use server';
import { revalidatePath } from 'next/cache';
import { createCustomerInput } from '@/app/(app)/customers/new/_lib/wizard/schemas';
import { logAudit } from '@/lib/audit-log';
import { authedInputAction } from '@/lib/authed-action';
import { consumeForceFailure } from '@/lib/force-failure';
import { conflict, err, ok } from '@/lib/result';
import { pushCustomer } from '@/server/store';
import type { Customer } from '@/server/types';
const isUniqueViolation = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: unknown }).code === '23505';
const FORCE_FAILURE_DELAY_MS = 200;
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const createCustomer = authedInputAction(
'member',
createCustomerInput,
async (input, ctx) => {
if (consumeForceFailure(ctx.userId)) {
await delay(FORCE_FAILURE_DELAY_MS);
return err('internal', 'Forced action failure for verification');
}
let row: Customer;
try {
row = pushCustomer({
orgId: ctx.orgId,
firstName: input.contact.firstName,
lastName: input.contact.lastName,
email: input.contact.email,
phone: input.contact.phone,
...input.billing,
defaultCurrency: input.preferences.defaultCurrency,
language: input.preferences.language,
notificationChannels: input.preferences.channels,
});
} catch (error) {
if (isUniqueViolation(error)) {
return conflict(
'A customer with this email already exists in this organization.',
null,
);
}
throw error;
}
logAudit({
orgId: ctx.orgId,
actorUserId: ctx.userId,
action: 'customer.created',
subjectId: row.id,
});
revalidatePath('/customers');
return ok({ id: row.id });
},
);

The inspector’s “Arm force-failure” button sets a per-user flag; this reads-and-clears it, sleeps 200ms to make the pending state visible, and returns the exact err('internal') shape a real action returns, so the button’s failure branch is built against it.

'use server';
import { revalidatePath } from 'next/cache';
import { createCustomerInput } from '@/app/(app)/customers/new/_lib/wizard/schemas';
import { logAudit } from '@/lib/audit-log';
import { authedInputAction } from '@/lib/authed-action';
import { consumeForceFailure } from '@/lib/force-failure';
import { conflict, err, ok } from '@/lib/result';
import { pushCustomer } from '@/server/store';
import type { Customer } from '@/server/types';
const isUniqueViolation = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: unknown }).code === '23505';
const FORCE_FAILURE_DELAY_MS = 200;
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const createCustomer = authedInputAction(
'member',
createCustomerInput,
async (input, ctx) => {
if (consumeForceFailure(ctx.userId)) {
await delay(FORCE_FAILURE_DELAY_MS);
return err('internal', 'Forced action failure for verification');
}
let row: Customer;
try {
row = pushCustomer({
orgId: ctx.orgId,
firstName: input.contact.firstName,
lastName: input.contact.lastName,
email: input.contact.email,
phone: input.contact.phone,
...input.billing,
defaultCurrency: input.preferences.defaultCurrency,
language: input.preferences.language,
notificationChannels: input.preferences.channels,
});
} catch (error) {
if (isUniqueViolation(error)) {
return conflict(
'A customer with this email already exists in this organization.',
null,
);
}
throw error;
}
logAudit({
orgId: ctx.orgId,
actorUserId: ctx.userId,
action: 'customer.created',
subjectId: row.id,
});
revalidatePath('/customers');
return ok({ id: row.id });
},
);

The four-slice draft maps straight onto the Customer row. Contact fields land in their own columns, no name concatenation; billing spreads in whole because its keys already match; preferences fans out, with channels becoming notificationChannels and the rest mapping by name. The in-memory store stands in for Postgres and the shapes line up.

'use server';
import { revalidatePath } from 'next/cache';
import { createCustomerInput } from '@/app/(app)/customers/new/_lib/wizard/schemas';
import { logAudit } from '@/lib/audit-log';
import { authedInputAction } from '@/lib/authed-action';
import { consumeForceFailure } from '@/lib/force-failure';
import { conflict, err, ok } from '@/lib/result';
import { pushCustomer } from '@/server/store';
import type { Customer } from '@/server/types';
const isUniqueViolation = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: unknown }).code === '23505';
const FORCE_FAILURE_DELAY_MS = 200;
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const createCustomer = authedInputAction(
'member',
createCustomerInput,
async (input, ctx) => {
if (consumeForceFailure(ctx.userId)) {
await delay(FORCE_FAILURE_DELAY_MS);
return err('internal', 'Forced action failure for verification');
}
let row: Customer;
try {
row = pushCustomer({
orgId: ctx.orgId,
firstName: input.contact.firstName,
lastName: input.contact.lastName,
email: input.contact.email,
phone: input.contact.phone,
...input.billing,
defaultCurrency: input.preferences.defaultCurrency,
language: input.preferences.language,
notificationChannels: input.preferences.channels,
});
} catch (error) {
if (isUniqueViolation(error)) {
return conflict(
'A customer with this email already exists in this organization.',
null,
);
}
throw error;
}
logAudit({
orgId: ctx.orgId,
actorUserId: ctx.userId,
action: 'customer.created',
subjectId: row.id,
});
revalidatePath('/customers');
return ok({ id: row.id });
},
);

pushCustomer throws a { code: '23505' }-shaped error on a duplicate (orgId, email) — 23505 is Postgres’s unique-violation SQLSTATE, kept so the code reads like the real thing. The catch maps that one code to conflict(…, null) and rethrows everything else into authedInputAction’s internal default.

'use server';
import { revalidatePath } from 'next/cache';
import { createCustomerInput } from '@/app/(app)/customers/new/_lib/wizard/schemas';
import { logAudit } from '@/lib/audit-log';
import { authedInputAction } from '@/lib/authed-action';
import { consumeForceFailure } from '@/lib/force-failure';
import { conflict, err, ok } from '@/lib/result';
import { pushCustomer } from '@/server/store';
import type { Customer } from '@/server/types';
const isUniqueViolation = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: unknown }).code === '23505';
const FORCE_FAILURE_DELAY_MS = 200;
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const createCustomer = authedInputAction(
'member',
createCustomerInput,
async (input, ctx) => {
if (consumeForceFailure(ctx.userId)) {
await delay(FORCE_FAILURE_DELAY_MS);
return err('internal', 'Forced action failure for verification');
}
let row: Customer;
try {
row = pushCustomer({
orgId: ctx.orgId,
firstName: input.contact.firstName,
lastName: input.contact.lastName,
email: input.contact.email,
phone: input.contact.phone,
...input.billing,
defaultCurrency: input.preferences.defaultCurrency,
language: input.preferences.language,
notificationChannels: input.preferences.channels,
});
} catch (error) {
if (isUniqueViolation(error)) {
return conflict(
'A customer with this email already exists in this organization.',
null,
);
}
throw error;
}
logAudit({
orgId: ctx.orgId,
actorUserId: ctx.userId,
action: 'customer.created',
subjectId: row.id,
});
revalidatePath('/customers');
return ok({ id: row.id });
},
);

On the happy path: write the audit row, revalidate the customers list so it picks up the new row, and return the id for the redirect. pushCustomer runs before logAudit, so a duplicate throws before any audit row is written and the log stays clean on a conflict.

1 / 1

Three decisions carry this file.

The direct-object wrapper, not the form-bound one. authedInputAction takes a plain object; authedAction takes (_prev, formData) for useActionState. The data here already lives parsed in the client store, so the direct-object wrapper fits. Server Actions, the canonical Result, and the parse-authorize-mutate shape are taught in Server Actions; here the action just applies them.

The re-parse is correctness, not UX. It is tempting to trust the client gate and skip the schema, since the user already cleared every step. Don’t: the action is a public endpoint, and createCustomerInput parsing the composite payload is what makes a malformed POST return { ok: false, error: { code: 'validation' } } instead of writing garbage. The gate stops honest mistakes; the parse stops everything else.

Ordering stands in for a transaction. The audit log stays consistent with the customer table on a conflict only because pushCustomer throws before logAudit is reached. That is an ordering guarantee, not atomic rollback: against a live database you would wrap the insert and the audit write in one transaction so a failure rolls both back. Here the ordering is enough, and the conflict test asserts it.

step-4/page.tsx is read-only: it pulls the three data slices, renders them back, and shows the submit button below. Only the selector needs a second look.

step-4/page.tsx
'use client';
import { useShallow } from 'zustand/react/shallow';
import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';
import { SubmitButton } from '@/app/(app)/customers/new/step-4/submit-button';
6 collapsed lines
const Row = ({ label, value }: { label: string; value: string }) => (
<div className="flex justify-between gap-4 border-b py-1.5 last:border-b-0">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-right font-medium">{value}</dd>
</div>
);
const Step4Page = () => {
const { contact, billing, preferences } = useWizardStore(
useShallow((s) => ({
contact: s.contact,
billing: s.billing,
preferences: s.preferences,
})),
);
42 collapsed lines
return (
<div data-testid="step-4" className="space-y-6">
<h2 className="text-lg font-medium">Review</h2>
<section data-testid="review-contact" className="space-y-2">
<h3 className="text-sm font-medium">Contact</h3>
<dl className="rounded-lg border p-3 text-sm">
<Row
label="Name"
value={`${contact.firstName} ${contact.lastName}`}
/>
<Row label="Email" value={contact.email} />
<Row label="Phone" value={contact.phone} />
</dl>
</section>
<section data-testid="review-billing" className="space-y-2">
<h3 className="text-sm font-medium">Billing</h3>
<dl className="rounded-lg border p-3 text-sm">
<Row
label="Address"
value={`${billing.line1}${billing.line2 ? `, ${billing.line2}` : ''}, ${billing.city} ${billing.region} ${billing.postalCode}, ${billing.country}`}
/>
<Row label="Tax ID" value={billing.taxId} />
<Row label="Payment terms" value={billing.paymentTerms} />
</dl>
</section>
<section data-testid="review-preferences" className="space-y-2">
<h3 className="text-sm font-medium">Preferences</h3>
<dl className="rounded-lg border p-3 text-sm">
<Row label="Currency" value={preferences.defaultCurrency} />
<Row label="Language" value={preferences.language} />
<Row
label="Channels"
value={preferences.channels.join(', ') || ''}
/>
</dl>
</section>
<SubmitButton />
</div>
);
};
export default Step4Page;

The selector maps three slice objects into one fresh literal { contact, billing, preferences } on every render. Without useShallow, the store’s default Object.is equality compares this render’s literal against the last, finds a different object, and re-runs the subscriber on every store change. useShallow swaps in a shallow comparison: same contact, billing, and preferences references means no re-render. This is the case the reflex is built for, a selector that produces a new object each call. Every other selector in the wizard returns a primitive or a stable reference, so they stay on the default check.

step-4/submit-button.tsx is the client half. It reads its own payload pick, runs the action inside a transition, and owns the two branches.

'use client';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';
import { createCustomer } from '@/app/(app)/customers/new/_lib/wizard/actions';
import { Button } from '@/components/ui/button';
export const SubmitButton = () => {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const reset = useWizardStore((s) => s.reset);
const { contact, billing, preferences } = useWizardStore(
useShallow((s) => ({
contact: s.contact,
billing: s.billing,
preferences: s.preferences,
})),
);
const router = useRouter();
const onSubmit = () => {
setError(null);
startTransition(async () => {
const result = await createCustomer({ contact, billing, preferences });
if (!result.ok) {
setError(result.error.userMessage);
return;
}
reset();
router.push(`/customers/${result.data.id}` as Route);
});
};
return (
<div className="space-y-2">
{error !== null ? (
<p data-testid="submit-error" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button
type="button"
data-testid="wizard-submit"
disabled={isPending}
onClick={onSubmit}
>
{isPending ? 'Creating…' : 'Create customer'}
</Button>
</div>
);
};

isPending does two jobs: it drives the button label (“Creating…” while in flight) and the guard. The button is disabled={isPending}, so the first click flips isPending true and a second click has no enabled button to fire. The double-submit guard is free.

'use client';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';
import { createCustomer } from '@/app/(app)/customers/new/_lib/wizard/actions';
import { Button } from '@/components/ui/button';
export const SubmitButton = () => {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const reset = useWizardStore((s) => s.reset);
const { contact, billing, preferences } = useWizardStore(
useShallow((s) => ({
contact: s.contact,
billing: s.billing,
preferences: s.preferences,
})),
);
const router = useRouter();
const onSubmit = () => {
setError(null);
startTransition(async () => {
const result = await createCustomer({ contact, billing, preferences });
if (!result.ok) {
setError(result.error.userMessage);
return;
}
reset();
router.push(`/customers/${result.data.id}` as Route);
});
};
return (
<div className="space-y-2">
{error !== null ? (
<p data-testid="submit-error" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button
type="button"
data-testid="wizard-submit"
disabled={isPending}
onClick={onSubmit}
>
{isPending ? 'Creating…' : 'Create customer'}
</Button>
</div>
);
};

The button assembles the same composite the review renders, for the same reason: three slice objects into one literal, so useShallow keeps the subscription stable. reset is read through its own selector above; it is a stable function reference, so it stays on the default check.

'use client';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';
import { createCustomer } from '@/app/(app)/customers/new/_lib/wizard/actions';
import { Button } from '@/components/ui/button';
export const SubmitButton = () => {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const reset = useWizardStore((s) => s.reset);
const { contact, billing, preferences } = useWizardStore(
useShallow((s) => ({
contact: s.contact,
billing: s.billing,
preferences: s.preferences,
})),
);
const router = useRouter();
const onSubmit = () => {
setError(null);
startTransition(async () => {
const result = await createCustomer({ contact, billing, preferences });
if (!result.ok) {
setError(result.error.userMessage);
return;
}
reset();
router.push(`/customers/${result.data.id}` as Route);
});
};
return (
<div className="space-y-2">
{error !== null ? (
<p data-testid="submit-error" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button
type="button"
data-testid="wizard-submit"
disabled={isPending}
onClick={onSubmit}
>
{isPending ? 'Creating…' : 'Create customer'}
</Button>
</div>
);
};

The failure branch sets the error message in local state and returns, without resetting. A network blip or a forced failure leaves the whole draft intact, so the user fixes the problem and clicks again. The message lives in useState, born and cleared with the attempt, never in the store.

'use client';
import type { Route } from 'next';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';
import { createCustomer } from '@/app/(app)/customers/new/_lib/wizard/actions';
import { Button } from '@/components/ui/button';
export const SubmitButton = () => {
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const reset = useWizardStore((s) => s.reset);
const { contact, billing, preferences } = useWizardStore(
useShallow((s) => ({
contact: s.contact,
billing: s.billing,
preferences: s.preferences,
})),
);
const router = useRouter();
const onSubmit = () => {
setError(null);
startTransition(async () => {
const result = await createCustomer({ contact, billing, preferences });
if (!result.ok) {
setError(result.error.userMessage);
return;
}
reset();
router.push(`/customers/${result.data.id}` as Route);
});
};
return (
<div className="space-y-2">
{error !== null ? (
<p data-testid="submit-error" className="text-sm text-destructive">
{error}
</p>
) : null}
<Button
type="button"
data-testid="wizard-submit"
disabled={isPending}
onClick={onSubmit}
>
{isPending ? 'Creating…' : 'Create customer'}
</Button>
</div>
);
};

On success, reset the store first, then redirect to the new customer’s detail page, whose id comes from result.data and is never stashed in the store. Reset-before-push is the discipline: here the layout unmounts on navigation so a fresh store mounts anyway, but the same code must be correct where the layout survives the reset.

1 / 1

Two more decisions, beyond what the annotations cover.

useTransition over useState<boolean>. You could track a pending boolean by hand, setBusy(true) before and setBusy(false) in a finally. useTransition does it for you and keeps the work inside a transition, so React treats the pending UI as non-urgent and the guard falls out of the same isPending. The hook is taught in Marking updates as non-urgent.

A button handler, not <form action>. On a form whose values the user just typed into fields, <form action={createCustomer}> with useActionState is the right call. It is wrong here: the values are already parsed in the client store, and routing them back out through FormData only to parse them again is ceremony with no upside. When data lives in a client store, the direct-object call wins.

In production, reset() also fires from the active-organization-switch and sign-out flows, so one org’s half-typed draft never survives into another org’s session. The org-switch action lives back in the organizations chapter, and wiring the reset into it is a single line — out of scope here, but the seam you built is the hook it plugs into.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 4

It calls createCustomer directly against the shared in-memory store, with no browser or dev server, and checks the three server-side outcomes the action owns. Wired correctly, it passes:

✓ lesson-verification/Lesson 4.ts (6 tests)
Test Files 1 passed (1)
Tests 6 passed (6)

The tests reach the action but can’t cheaply drive React, the transition, or the redirect. Confirm the rest by hand at the inspector (/inspector) and in the browser:

Completing four valid steps and submitting fires exactly one Server-Action POST returning { ok: true, data: { id } }; the audit-log tail gains one customer.created row; the router lands on /customers/[newId] and the real detail page renders.
untested
After a successful submit, navigating back to step 1 shows empty fields and an initial snapshot. Temporarily remove reset() from the success branch and repeat — the previous customer’s data is still there, which confirms reset is what closed the loop; revert.
untested
With “Arm force-failure” on, submitting shows the inline error under the button and leaves the draft intact. Temporarily add reset() to the failure branch and repeat — the draft is wiped on the forced failure, which confirms the failure branch must not reset; revert.
untested
”Force double-submit” produces one POST and one audit row — isPending blocks the second handler.
untested
A session acting in org X creates the customer in org X’s list, not org Y’s — switch the acting identity in the inspector header and confirm tenancy holds at the action.
untested
Grepping useShallow returns exactly two hits — step-4/page.tsx and step-4/submit-button.tsx — and the action file imports schemas only, never the store or the hook.
untested

With every box ticked, the wizard’s loop runs end to end and the chapter is done.