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"; | |
| 1 | 8 | ||
| 9 | + | const initial: CheckoutState = { status: "idle" }; | |
| 2 | 10 | ||
| 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; | |
| 3 | 18 | ||
| 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 | + | } |