Skip to content
Chapter 47Lesson 2

Create an invoice

Build a CRUD create path: a Zod schema, a Server Action, and a progressively enhanced form.

The read surface is done, but nothing on it changes anything yet. By the end of this lesson you can open /invoices/new, fill the form, and submit: a valid invoice writes its row to Postgres and lands you on /invoices/[newId], where your existing detail page renders it. A broken one — an empty total, a date that isn’t a date — returns the form with a message under each bad field, your valid entries intact, and the submit button re-enabled. It works even with JavaScript switched off, and its shape is the one every later mutation in the chapter reuses.

You are wiring three pieces together: a schema, a Server Action, and a form.

The schema is the contract. Derive it from the Drizzle invoices table with createInsertSchema instead of hand-writing a validator, so the action’s input shape and the form’s input names both trace back to one table definition. Change a column and the schema changes with it, which makes drift between the three layers impossible.

The action follows the five-seam shape from Result, or throw: parse, authorize, mutate, revalidate, return. Parse the FormData with safeParse. Read the active org and user only after the parse succeeds, so a malformed submission never costs an auth lookup; both come from getActiveContext(), the stub the starter ships, where real auth will later slot in. Insert through the pooled Drizzle client, revalidatePath('/invoices') so the list picks up the new row, and return a Result rather than throw; on success, redirect to the new detail page.

The form is a Client Component holding almost no client state. The inputs are uncontrolled — name plus defaultValue, nothing bound to React — which is what keeps the JavaScript-disabled path working: with no script, the browser POSTs the form and follows the redirect itself. Field errors render from the action’s Result.error.fieldErrors, not local state: the form renders, the server owns the truth. Mirror the schema’s rules in the native validation attributes too, so a missing constraint and a stricter schema rule can’t disagree. The <SubmitButton> and <FieldError> you build here are shared; every later form in the chapter imports them.

Out of scope, each in its own lesson: editing, deleting, the optimistic create, and the Drizzle transaction. Build only the create path.

Submitting /invoices/new with a valid invoice writes the row, redirects to its /invoices/[newId] detail page, and the new row appears on /invoices.
tested
The same valid submission succeeds with JavaScript disabled — the browser POSTs to the action URL and navigates to the detail page.
untested
Submitting with total blank and a malformed dueAt re-renders the form with a message under each offending field, sourced from the action’s Result.
tested
On that validation failure the other fields keep their typed values and the submit button re-enables.
tested
The submit button shows a spinner while the action is in flight.
untested
Each field’s input constraints (required, numeric, date) match the schema’s rules, and the red invalid styling appears on a field only after a submit the server flagged it on, not on mount.
untested

Implement createInvoiceInputSchema, createInvoice, the shared <SubmitButton> and <FieldError>, and the create path of NewInvoiceForm against the brief and the tests. Try it before you open the reference; the shape sticks better once you’ve wrestled with it.

Reference solution and walkthrough

In lib/invoices/mutation-schemas.ts, the whole file is the create schema and its two type aliases:

lib/invoices/mutation-schemas.ts
import { createInsertSchema } from 'drizzle-zod';
import { z } from 'zod';
import { invoices } from '@/db/schema';
export const createInvoiceInputSchema = createInsertSchema(invoices, {
number: (s) => s.min(1).max(50),
total: (s) =>
s
.regex(/^\d+(\.\d{1,2})?$/, 'Enter a valid amount (max 2 decimals)')
.refine((v) => Number(v) >= 0, 'Total must be non-negative'),
customerId: z.uuid(),
issuedAt: z.coerce.date('Enter a valid date'),
dueAt: z.coerce.date('Enter a valid date'),
}).omit({ organizationId: true, createdBy: true, createdAt: true });
export type CreateInvoiceInput = z.input<typeof createInvoiceInputSchema>;
export type CreateInvoiceOutput = z.output<typeof createInvoiceInputSchema>;

total stays a string: a regex checks its shape, a .refine checks its sign, and the action inserts it as-is. Never z.coerce.number() here — the paragraph below says why.

lib/invoices/mutation-schemas.ts
import { createInsertSchema } from 'drizzle-zod';
import { z } from 'zod';
import { invoices } from '@/db/schema';
export const createInvoiceInputSchema = createInsertSchema(invoices, {
number: (s) => s.min(1).max(50),
total: (s) =>
s
.regex(/^\d+(\.\d{1,2})?$/, 'Enter a valid amount (max 2 decimals)')
.refine((v) => Number(v) >= 0, 'Total must be non-negative'),
customerId: z.uuid(),
issuedAt: z.coerce.date('Enter a valid date'),
dueAt: z.coerce.date('Enter a valid date'),
}).omit({ organizationId: true, createdBy: true, createdAt: true });
export type CreateInvoiceInput = z.input<typeof createInvoiceInputSchema>;
export type CreateInvoiceOutput = z.output<typeof createInvoiceInputSchema>;

These three are server-owned — the action supplies the org and user, the database stamps the timestamp — so the form never sends them. The id column stays in: it’s optional and column-defaulted, and the optimistic-create lesson posts a client-generated id through it.

lib/invoices/mutation-schemas.ts
import { createInsertSchema } from 'drizzle-zod';
import { z } from 'zod';
import { invoices } from '@/db/schema';
export const createInvoiceInputSchema = createInsertSchema(invoices, {
number: (s) => s.min(1).max(50),
total: (s) =>
s
.regex(/^\d+(\.\d{1,2})?$/, 'Enter a valid amount (max 2 decimals)')
.refine((v) => Number(v) >= 0, 'Total must be non-negative'),
customerId: z.uuid(),
issuedAt: z.coerce.date('Enter a valid date'),
dueAt: z.coerce.date('Enter a valid date'),
}).omit({ organizationId: true, createdBy: true, createdAt: true });
export type CreateInvoiceInput = z.input<typeof createInvoiceInputSchema>;
export type CreateInvoiceOutput = z.output<typeof createInvoiceInputSchema>;

z.input is the raw FormData shape, dates and total as strings; z.output is the coerced shape the action body works with after a successful parse.

1 / 1

The override callback and .omit follow drizzle-zod: one source of truth. One field needs care: total looks numeric but isn’t. Postgres numeric(12,2) is arbitrary-precision, so drizzle-zod types it as a string to avoid the float rounding z.coerce.number() would introduce on money.

In lib/invoices/actions.ts, the top-level 'use server' marks every export as a Server Action, making the network boundary explicit. Here is createInvoice:

lib/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/db/index';
import { invoices } from '@/db/schema';
import { getActiveContext } from '@/lib/auth-stub';
import { createInvoiceInputSchema } from '@/lib/invoices/mutation-schemas';
import { err, isUniqueViolation, type Result } from '@/lib/result';
export const createInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = createInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId, userId } = await getActiveContext();
let row: { id: string } | undefined;
try {
[row] = await db
.insert(invoices)
.values({ ...parsed.data, organizationId, createdBy: userId })
.returning({ id: invoices.id });
revalidatePath('/invoices');
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
if (!row) {
return err('internal', 'Invoice could not be created.');
}
redirect(`/invoices/${row.id}`);
};

Parse. Run Object.fromEntries(formData) through safeParse. On failure, return err('validation', ...) carrying z.flattenError(parsed.error).fieldErrors — a flat Record<string, string[]> keyed by field name, exactly what <FieldError> reads. No throw: a bad submit is expected, not exceptional.

lib/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/db/index';
import { invoices } from '@/db/schema';
import { getActiveContext } from '@/lib/auth-stub';
import { createInvoiceInputSchema } from '@/lib/invoices/mutation-schemas';
import { err, isUniqueViolation, type Result } from '@/lib/result';
export const createInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = createInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId, userId } = await getActiveContext();
let row: { id: string } | undefined;
try {
[row] = await db
.insert(invoices)
.values({ ...parsed.data, organizationId, createdBy: userId })
.returning({ id: invoices.id });
revalidatePath('/invoices');
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
if (!row) {
return err('internal', 'Invoice could not be created.');
}
redirect(`/invoices/${row.id}`);
};

Authorize. Read the tenant context after the parse, so a parse failure never pays for an auth lookup. This is the seam where a real auth wrapper drops in later; the await is already here because that wrapper is async.

lib/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/db/index';
import { invoices } from '@/db/schema';
import { getActiveContext } from '@/lib/auth-stub';
import { createInvoiceInputSchema } from '@/lib/invoices/mutation-schemas';
import { err, isUniqueViolation, type Result } from '@/lib/result';
export const createInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = createInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId, userId } = await getActiveContext();
let row: { id: string } | undefined;
try {
[row] = await db
.insert(invoices)
.values({ ...parsed.data, organizationId, createdBy: userId })
.returning({ id: invoices.id });
revalidatePath('/invoices');
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
if (!row) {
return err('internal', 'Invoice could not be created.');
}
redirect(`/invoices/${row.id}`);
};

Mutate. Insert { ...parsed.data, organizationId, createdBy: userId } and .returning({ id }) for the new row’s id. revalidatePath('/invoices') sits inside the try, right after the insert succeeds, so the list picks up the new row.

lib/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/db/index';
import { invoices } from '@/db/schema';
import { getActiveContext } from '@/lib/auth-stub';
import { createInvoiceInputSchema } from '@/lib/invoices/mutation-schemas';
import { err, isUniqueViolation, type Result } from '@/lib/result';
export const createInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = createInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId, userId } = await getActiveContext();
let row: { id: string } | undefined;
try {
[row] = await db
.insert(invoices)
.values({ ...parsed.data, organizationId, createdBy: userId })
.returning({ id: invoices.id });
revalidatePath('/invoices');
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
if (!row) {
return err('internal', 'Invoice could not be created.');
}
redirect(`/invoices/${row.id}`);
};

The conflict catch. The unique (organizationId, number) constraint can trip on a duplicate number — isUniqueViolation(e) maps the Postgres error to a conflict Result; anything else re-throws to the framework’s error boundary. The if (!row) guard narrows the returning type before the redirect.

lib/invoices/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/db/index';
import { invoices } from '@/db/schema';
import { getActiveContext } from '@/lib/auth-stub';
import { createInvoiceInputSchema } from '@/lib/invoices/mutation-schemas';
import { err, isUniqueViolation, type Result } from '@/lib/result';
export const createInvoice = async (
_prevState: Result<{ id: string }> | null,
formData: FormData,
): Promise<Result<{ id: string }>> => {
const parsed = createInvoiceInputSchema.safeParse(
Object.fromEntries(formData),
);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const { organizationId, userId } = await getActiveContext();
let row: { id: string } | undefined;
try {
[row] = await db
.insert(invoices)
.values({ ...parsed.data, organizationId, createdBy: userId })
.returning({ id: invoices.id });
revalidatePath('/invoices');
} catch (e) {
if (isUniqueViolation(e)) {
return err(
'conflict',
'An invoice with that number already exists for this org.',
);
}
throw e;
}
if (!row) {
return err('internal', 'Invoice could not be created.');
}
redirect(`/invoices/${row.id}`);
};

Redirect. redirect works by throwing a control-flow signal, so it sits outside the try/catch on purpose: inside, the conflict catch would swallow it and turn a successful create into a caught exception. On success it sends the user to the new detail page.

1 / 1

revalidatePath and the five-seam shape come from After the write. The action returns Result<{ id: string }>, but the happy path redirects rather than returns, so that type describes only the error branches the form sees. The _prevState argument is the previous Result that useActionState threads in; this action ignores it.

<SubmitButton> and <FieldError> are used by every form in this chapter, so they live in app/_components/, not next to any one form.

<SubmitButton> reads the form’s pending state with useFormStatus() and disables itself with a spinner while the action runs. useFormStatus only works inside a <form>, which is why this is a component rather than a disabled prop, the pattern from useFormStatus and the SubmitButton.

app/_components/submit-button.tsx
'use client';
import { Loader2 } from 'lucide-react';
import type { ComponentProps, ReactNode } from 'react';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';
type SubmitButtonProps = {
children: ReactNode;
variant?: ComponentProps<typeof Button>['variant'];
};
export const SubmitButton = ({ children, variant }: SubmitButtonProps) => {
const { pending } = useFormStatus();
return (
<Button type="submit" variant={variant} disabled={pending}>
{pending && (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
)}
{children}
</Button>
);
};

motion-reduce:animate-none gives a static icon to users whose OS requests reduced motion. The optional variant forwards to the shadcn <Button>, so the delete form later can render a destructive submit.

<FieldError> takes a field name and the action’s fieldErrors, and renders the first message for that field, or nothing when the field is clean:

app/_components/field-error.tsx
type FieldErrorProps = {
name: string;
fieldErrors: Record<string, string[]> | undefined;
};
export const FieldError = ({ name, fieldErrors }: FieldErrorProps) => {
const message = fieldErrors?.[name]?.[0];
if (!message) {
return null;
}
return (
<p
id={`${name}-error`}
className="mt-1 text-sm text-destructive"
role="alert"
>
{message}
</p>
);
};

The id={`${name}-error`} matches the aria-describedby each control points at, so a screen reader announces the message when focus lands on the field, and role="alert" announces it the moment it appears. There’s no 'use client': the component has no client-only hooks, it just renders inside whichever form imports it.

app/invoices/new/new-invoice-form.tsx is the create form. It’s a Client Component because useActionState reads the action’s Result, yet it holds almost no state: the inputs are uncontrolled.

app/invoices/new/new-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { createInvoice } from '@/lib/invoices/actions';
import { statusSchema } from '@/lib/invoices/schema';
type NewInvoiceFormProps = {
customers: { id: string; name: string }[];
};
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so a validation re-render would otherwise blank the inputs;
// remounting the field cluster on each submit re-applies these as the initial
// uncontrolled values.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
const initialDefaults: Record<(typeof echoedFields)[number], string> = {
customerId: '',
number: '',
status: 'draft',
total: '',
issuedAt: '',
dueAt: '',
currency: 'USD',
};
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => {
const [state, formAction] = useActionState(createInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState(initialDefaults);
const [submitCount, setSubmitCount] = useState(0);
// React 19 resets an uncontrolled `<form action>` on every commit — including
// a validation failure — so the typed values are echoed back as the next
// defaultValue set and the field cluster is remounted (the `key`) to re-apply
// them.
const echoSubmittedValues = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
};
return (
<section className="flex flex-col gap-4">
<form
key={submitCount}
action={formAction}
onSubmit={(event) =>
echoSubmittedValues(new FormData(event.currentTarget))
}
data-testid="new-invoice-form"
className="flex flex-col gap-4"
>
<div className="grid gap-2">
<Label htmlFor="customerId">Customer</Label>
<NativeSelect
id="customerId"
name="customerId"
defaultValue={defaults.customerId}
aria-describedby="customerId-error"
aria-invalid={!!fieldErrors?.customerId?.[0]}
>
<NativeSelectOption value="">Select a customer</NativeSelectOption>
{customers.map((customer) => (
<NativeSelectOption key={customer.id} value={customer.id}>
{customer.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldError name="customerId" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="number">Number</Label>
<Input
id="number"
name="number"
type="text"
required
autoComplete="off"
defaultValue={defaults.number}
aria-describedby="number-error"
aria-invalid={!!fieldErrors?.number?.[0]}
/>
<FieldError name="number" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="status">Status</Label>
<NativeSelect
id="status"
name="status"
defaultValue={defaults.status}
aria-describedby="status-error"
aria-invalid={!!fieldErrors?.status?.[0]}
>
{statusSchema.options.map((status) => (
<NativeSelectOption key={status} value={status}>
{status}
</NativeSelectOption>
))}
</NativeSelect>
<FieldError name="status" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="total">Total</Label>
<Input
id="total"
name="total"
type="number"
step="0.01"
min="0"
required
inputMode="decimal"
defaultValue={defaults.total}
aria-describedby="total-error"
aria-invalid={!!fieldErrors?.total?.[0]}
/>
<FieldError name="total" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="issuedAt">Issued</Label>
<Input
id="issuedAt"
name="issuedAt"
type="date"
required
defaultValue={defaults.issuedAt}
aria-describedby="issuedAt-error"
aria-invalid={!!fieldErrors?.issuedAt?.[0]}
/>
<FieldError name="issuedAt" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="dueAt">Due</Label>
<Input
id="dueAt"
name="dueAt"
type="date"
required
defaultValue={defaults.dueAt}
aria-describedby="dueAt-error"
aria-invalid={!!fieldErrors?.dueAt?.[0]}
/>
<FieldError name="dueAt" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
name="currency"
type="text"
defaultValue={defaults.currency}
aria-describedby="currency-error"
aria-invalid={!!fieldErrors?.currency?.[0]}
/>
<FieldError name="currency" fieldErrors={fieldErrors} />
</div>
{state?.ok === false && state.error.code !== 'validation' && (
<p role="alert" className="text-destructive">
{state.error.userMessage}
</p>
)}
<SubmitButton>Create invoice</SubmitButton>
</form>
</section>
);
};

useActionState(createInvoice, null) returns the latest Result as state and a bound formAction for the form. fieldErrors is pulled from state only on a failure — that one derived value drives every <FieldError> and every aria-invalid below.

app/invoices/new/new-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { createInvoice } from '@/lib/invoices/actions';
import { statusSchema } from '@/lib/invoices/schema';
type NewInvoiceFormProps = {
customers: { id: string; name: string }[];
};
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so a validation re-render would otherwise blank the inputs;
// remounting the field cluster on each submit re-applies these as the initial
// uncontrolled values.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
const initialDefaults: Record<(typeof echoedFields)[number], string> = {
customerId: '',
number: '',
status: 'draft',
total: '',
issuedAt: '',
dueAt: '',
currency: 'USD',
};
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => {
const [state, formAction] = useActionState(createInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState(initialDefaults);
const [submitCount, setSubmitCount] = useState(0);
// React 19 resets an uncontrolled `<form action>` on every commit — including
// a validation failure — so the typed values are echoed back as the next
// defaultValue set and the field cluster is remounted (the `key`) to re-apply
// them.
const echoSubmittedValues = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
};
return (
<section className="flex flex-col gap-4">
<form
key={submitCount}
action={formAction}
onSubmit={(event) =>
echoSubmittedValues(new FormData(event.currentTarget))
}
data-testid="new-invoice-form"
className="flex flex-col gap-4"
>
<div className="grid gap-2">
<Label htmlFor="customerId">Customer</Label>
<NativeSelect
id="customerId"
name="customerId"
defaultValue={defaults.customerId}
aria-describedby="customerId-error"
aria-invalid={!!fieldErrors?.customerId?.[0]}
>
<NativeSelectOption value="">Select a customer</NativeSelectOption>
{customers.map((customer) => (
<NativeSelectOption key={customer.id} value={customer.id}>
{customer.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldError name="customerId" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="number">Number</Label>
<Input
id="number"
name="number"
type="text"
required
autoComplete="off"
defaultValue={defaults.number}
aria-describedby="number-error"
aria-invalid={!!fieldErrors?.number?.[0]}
/>
<FieldError name="number" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="status">Status</Label>
<NativeSelect
id="status"
name="status"
defaultValue={defaults.status}
aria-describedby="status-error"
aria-invalid={!!fieldErrors?.status?.[0]}
>
{statusSchema.options.map((status) => (
<NativeSelectOption key={status} value={status}>
{status}
</NativeSelectOption>
))}
</NativeSelect>
<FieldError name="status" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="total">Total</Label>
<Input
id="total"
name="total"
type="number"
step="0.01"
min="0"
required
inputMode="decimal"
defaultValue={defaults.total}
aria-describedby="total-error"
aria-invalid={!!fieldErrors?.total?.[0]}
/>
<FieldError name="total" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="issuedAt">Issued</Label>
<Input
id="issuedAt"
name="issuedAt"
type="date"
required
defaultValue={defaults.issuedAt}
aria-describedby="issuedAt-error"
aria-invalid={!!fieldErrors?.issuedAt?.[0]}
/>
<FieldError name="issuedAt" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="dueAt">Due</Label>
<Input
id="dueAt"
name="dueAt"
type="date"
required
defaultValue={defaults.dueAt}
aria-describedby="dueAt-error"
aria-invalid={!!fieldErrors?.dueAt?.[0]}
/>
<FieldError name="dueAt" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
name="currency"
type="text"
defaultValue={defaults.currency}
aria-describedby="currency-error"
aria-invalid={!!fieldErrors?.currency?.[0]}
/>
<FieldError name="currency" fieldErrors={fieldErrors} />
</div>
{state?.ok === false && state.error.code !== 'validation' && (
<p role="alert" className="text-destructive">
{state.error.userMessage}
</p>
)}
<SubmitButton>Create invoice</SubmitButton>
</form>
</section>
);
};

The cluster pattern is <div className="grid gap-2"> wrapping <Label htmlFor> + control + <FieldError>. The control mirrors the schema in native attributes — type="number", step="0.01", min="0", required, inputMode="decimal" — and wires aria-describedby and aria-invalid to the same field’s error. The defaultValue (not value) is what keeps the input uncontrolled.

app/invoices/new/new-invoice-form.tsx
'use client';
import { useActionState, useState } from 'react';
import { FieldError } from '@/app/_components/field-error';
import { SubmitButton } from '@/app/_components/submit-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
NativeSelect,
NativeSelectOption,
} from '@/components/ui/native-select';
import { createInvoice } from '@/lib/invoices/actions';
import { statusSchema } from '@/lib/invoices/schema';
type NewInvoiceFormProps = {
customers: { id: string; name: string }[];
};
// The fields whose typed values are echoed back as defaultValue on a failed
// submit. A `<form action={fn}>` fires requestFormReset on commit under
// react-dom 19, so a validation re-render would otherwise blank the inputs;
// remounting the field cluster on each submit re-applies these as the initial
// uncontrolled values.
const echoedFields = [
'customerId',
'number',
'status',
'total',
'issuedAt',
'dueAt',
'currency',
] as const;
const initialDefaults: Record<(typeof echoedFields)[number], string> = {
customerId: '',
number: '',
status: 'draft',
total: '',
issuedAt: '',
dueAt: '',
currency: 'USD',
};
export const NewInvoiceForm = ({ customers }: NewInvoiceFormProps) => {
const [state, formAction] = useActionState(createInvoice, null);
const fieldErrors = state?.ok === false ? state.error.fieldErrors : undefined;
const [defaults, setDefaults] = useState(initialDefaults);
const [submitCount, setSubmitCount] = useState(0);
// React 19 resets an uncontrolled `<form action>` on every commit — including
// a validation failure — so the typed values are echoed back as the next
// defaultValue set and the field cluster is remounted (the `key`) to re-apply
// them.
const echoSubmittedValues = (formData: FormData) => {
setDefaults(
Object.fromEntries(
echoedFields.map((field) => [field, String(formData.get(field) ?? '')]),
) as Record<(typeof echoedFields)[number], string>,
);
setSubmitCount((count) => count + 1);
};
return (
<section className="flex flex-col gap-4">
<form
key={submitCount}
action={formAction}
onSubmit={(event) =>
echoSubmittedValues(new FormData(event.currentTarget))
}
data-testid="new-invoice-form"
className="flex flex-col gap-4"
>
<div className="grid gap-2">
<Label htmlFor="customerId">Customer</Label>
<NativeSelect
id="customerId"
name="customerId"
defaultValue={defaults.customerId}
aria-describedby="customerId-error"
aria-invalid={!!fieldErrors?.customerId?.[0]}
>
<NativeSelectOption value="">Select a customer</NativeSelectOption>
{customers.map((customer) => (
<NativeSelectOption key={customer.id} value={customer.id}>
{customer.name}
</NativeSelectOption>
))}
</NativeSelect>
<FieldError name="customerId" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="number">Number</Label>
<Input
id="number"
name="number"
type="text"
required
autoComplete="off"
defaultValue={defaults.number}
aria-describedby="number-error"
aria-invalid={!!fieldErrors?.number?.[0]}
/>
<FieldError name="number" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="status">Status</Label>
<NativeSelect
id="status"
name="status"
defaultValue={defaults.status}
aria-describedby="status-error"
aria-invalid={!!fieldErrors?.status?.[0]}
>
{statusSchema.options.map((status) => (
<NativeSelectOption key={status} value={status}>
{status}
</NativeSelectOption>
))}
</NativeSelect>
<FieldError name="status" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="total">Total</Label>
<Input
id="total"
name="total"
type="number"
step="0.01"
min="0"
required
inputMode="decimal"
defaultValue={defaults.total}
aria-describedby="total-error"
aria-invalid={!!fieldErrors?.total?.[0]}
/>
<FieldError name="total" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="issuedAt">Issued</Label>
<Input
id="issuedAt"
name="issuedAt"
type="date"
required
defaultValue={defaults.issuedAt}
aria-describedby="issuedAt-error"
aria-invalid={!!fieldErrors?.issuedAt?.[0]}
/>
<FieldError name="issuedAt" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="dueAt">Due</Label>
<Input
id="dueAt"
name="dueAt"
type="date"
required
defaultValue={defaults.dueAt}
aria-describedby="dueAt-error"
aria-invalid={!!fieldErrors?.dueAt?.[0]}
/>
<FieldError name="dueAt" fieldErrors={fieldErrors} />
</div>
<div className="grid gap-2">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
name="currency"
type="text"
defaultValue={defaults.currency}
aria-describedby="currency-error"
aria-invalid={!!fieldErrors?.currency?.[0]}
/>
<FieldError name="currency" fieldErrors={fieldErrors} />
</div>
{state?.ok === false && state.error.code !== 'validation' && (
<p role="alert" className="text-destructive">
{state.error.userMessage}
</p>
)}
<SubmitButton>Create invoice</SubmitButton>
</form>
</section>
);
};

On submit the form echoes the typed values back so a validation re-render doesn’t blank them — explained below.

1 / 1

defaultValue, never value. Uncontrolled inputs carry no per-keystroke state, so the no-JavaScript path works untouched: the browser submits the raw form. The cost is React 19’s reset-on-commit, which blanks an uncontrolled <form action> on every commit, the validation re-render included. To survive that, onSubmit copies the typed values into a defaults state and bumps submitCount, the form’s key; the remount reseeds each field from defaults, so a failed submit keeps your work. onSubmit runs only with JavaScript on — the no-JS POST reloads the whole page, so there is no re-render to survive.

The field cluster is hand-rolled. shadcn’s Form* family would build these clusters, but it calls useFormField() and throws outside React Hook Form, which this project doesn’t install — useActionState owns the form state here. This is the call from Constraint Validation, the cheap layer.

The select is the native one. <NativeSelect> is a thin wrapper over a plain <select>, not Radix’s <Select>. A real <select> submits with the form and works with no JavaScript; the Radix component is a <div> tree that needs script to function.

A form-level banner carries non-validation errors. Validation errors already render under their fields, so the banner (state.error.code !== 'validation') is reserved for the other codes. A duplicate number comes back as a conflict and surfaces here as a single line, not under any one field, because the violation is on the org-and-number pair rather than a single value.

The Constraint Validation attributes mirror the schema one-to-one: required on every non-optional field, type="number" and type="date" matching the column, inputMode="decimal" on total for a numeric keypad, autoComplete="off" on number so the browser won’t suggest stale invoice numbers. The shadcn <Input>’s red invalid ring keys off aria-invalid (an aria-invalid:border-destructive Tailwind variant), which flips true only once a field has an entry in the server’s fieldErrors, so a field shows red only after a submit the server rejected, never on first paint — unlike the browser’s native :invalid, which lights up every empty required field on load. That same wiring is the accessibility pattern from No ARIA is better than bad ARIA: it marks the control invalid and points assistive tech at the message.

Run the lesson’s test suite:

Terminal window
pnpm test:lesson 2

The suite runs createInvoice against a real Postgres, so bring the database up, migrated, and seeded first (docker compose up -d, pnpm db:migrate, pnpm db:seed). A correctly wired create path gives you three passing tests:

✓ tests/lessons/Lesson 2.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)

The tests cover the server-side contract but can’t assert rendering or no-JavaScript behavior, so confirm those by hand:

In DevTools, open the command menu and run “Disable JavaScript”, reload /invoices/new, and submit a valid invoice — the browser navigates to /invoices/[newId] and pnpm db:studio shows the new row.
untested
With JavaScript on, submit with total blank and a malformed dueAt (temporarily drop required from those inputs to reach the server path) — a message renders under both fields, the other inputs keep their values, and the submit button re-enables. Restore required afterward.
untested
The submit button shows its spinner while the action is in flight.
untested
No field shows the red invalid ring on first paint — it appears on a field only after a submit the server flagged it on (its aria-invalid), never as a wall of red when the empty form loads.
untested