Review

Ski-patrol beacon pool: weekend checkout form via useActionState

Alpine volunteer patrol needs a progressive form to check out shared avalanche beacons for weekend duty. Wires React 19 useActionState to a server action that returns structured field/form errors (no throw-for-validation), keeps a real form action so the POST still works before hydration, and maps those errors into aria-invalid + role=alert UI without a client-only fetch path.

React 19Tier 3react-19useActionStateformsprogressive-enhancement

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

app/patrol/beacons/CheckoutForm.tsx+40-0
1+"use client";
2+
3+import { useActionState } from "react";
4+import {
5+ checkoutBeacon,
6+ type CheckoutState,
7+} from "./actions";
18
9+const initial: CheckoutState = { status: "idle" };
210
11+export function CheckoutForm({ unitId }: { unitId: string }) {
12+ const [state, formAction, pending] = useActionState(
13+ checkoutBeacon,
14+ initial,
15+ );
16+ const field = state.status === "error" ? state.fieldErrors : undefined;
17+ const formError = state.status === "error" ? state.formError : undefined;
318
19+ return (
20+ <form action={formAction} noValidate>
21+ <input type="hidden" name="unitId" value={unitId} />
22+ <label htmlFor="shift-end">Return by (local)</label>
23+ <input
24+ id="shift-end"
25+ name="shiftEnd"
26+ type="datetime-local"
27+ required
28+ aria-invalid={Boolean(field?.shiftEnd)}
29+ aria-describedby={field?.shiftEnd ? "shift-end-err" : undefined}
30+ />
31+ {field?.shiftEnd ? (
32+ <p id="shift-end-err" role="alert">{field.shiftEnd}</p>
33+ ) : null}
34+ {formError ? <p role="alert">{formError}</p> : null}
35+ {state.status === "ok" ? (
36+ <p role="status">Checked out — ticket {state.ticketId}</p>
37+ ) : null}
38+ <button type="submit" disabled={pending}>
39+ {pending ? "Reserving…" : "Check out beacon"}
40+ </button>
41+ </form>
42+ );
43+}