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.
Your mission
Section titled “Your mission”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.
/invoices/new with a valid invoice writes the row, redirects to its /invoices/[newId] detail page, and the new row appears on /invoices.total blank and a malformed dueAt re-renders the form with a message under each offending field, sourced from the action’s Result.Coding time
Section titled “Coding time”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
The create schema with drizzle-zod
Section titled “The create schema with drizzle-zod”In lib/invoices/mutation-schemas.ts, the whole file is the create schema and its two type aliases:
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.
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.
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.
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.
The action in five seams
Section titled “The action in five seams”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:
'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.
'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.
'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.
'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.
'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.
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.
Two shared components
Section titled “Two shared components”<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.
'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:
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.
The form
Section titled “The form”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.
'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.
'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.
'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.
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.
The Next.js guide this lesson tracks: action signature, safeParse fieldErrors, useActionState, and the SubmitButton.
Reference for the hook that threads the action's Result back to the form, plus the prevState first argument.
Why SubmitButton lives inside the form: the pending flag only works from a component rendered under <form>.
createInsertSchema, override callbacks, and .omit — the schema-from-table mechanics the contract is built on.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 2The 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:
/invoices/new, and submit a valid invoice — the browser navigates to /invoices/[newId] and pnpm db:studio shows the new row.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.aria-invalid), never as a wall of red when the empty form loads.