Repository navigation
Typed Action Dispatch — a pattern for end-to-end type-safe mutations #15020
zabi-babar
started this conversation in
Proposals
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Typed Action Dispatch — a pattern for end-to-end type-safe mutations
Summary
React Router 7 has made great strides in type safety for loaders and route data, but mutations still rely on untyped
FormDataas the serialization boundary between components and route actions. This means the response side of a mutation can be typed, but the request side cannot — leaving half the contract enforced by convention rather than the type system.This post outlines three specific issues with the current approach and proposes a pattern to address them, along with a working reference implementation.
Issue 1:
useFetcheris half-typedWith React Router 7's type inference,
useFetcher<typeof action>()gives you typed return data. But the submission side is completely untyped:The type system protects the response but is silent about the request. If the action changes its expected fields, the component compiles fine and breaks at runtime.
Issue 2:
FormDatais a lossy serialization boundaryformData.get()returnsFormDataEntryValue | null(string | File | null). Everything goes in as a string and comes out as a string. This means:Number(formData.get("priority"))— and you hope it's notNaNformData.get("active") === "true"— a string comparisonformData.getAll()— and the action has to know which fields are arraysJSON.stringifyinto a single field, or flatten with dot notation)Every action re-invents this parsing. Across 10, 20+ mutations in an app, this becomes a significant source of bugs and boilerplate.
Issue 3: The
switch(intent)pattern — a case for the strategy patternWhen a route handles multiple mutations, the common approach is to branch on an
intentfield:This works, but it's essentially a manual dispatch table — each branch parses different fields from the same untyped
FormData, with no shared structure and no compile-time verification that the component is sending the right fields for the right intent.This is a textbook case for the strategy pattern: define each mutation as a self-contained unit with its own typed payload and resolver, then let a single dispatch mechanism route to the correct one.
The pattern:
defineAction+resolveFormData+useActionFetcherThe idea is three primitives:
1. Define each mutation as a typed action
Each action carries its payload type, return type, and resolver in one place.
2. Replace the switch with a single dispatch
No switch. No manual parsing. The action type in the
FormDatamaps to the registered definition, the payload is deserialized with full type fidelity (including Dates, Maps, Sets, Files), and the resolver runs.3. Use a typed hook on the component side
submit()is fully typed to the action's payload. If you pass the wrong shape, the compiler catches it. The response indatais typed to the action's return type. NoFormDataconstruction, nouseEffectfor callbacks.What this gives you
defineActionthroughuseActionFetchertoresolveFormData. Change a field name and the compiler tells you everywhere that breaks.casebranch.pendingPayloaddeserializes the in-flightfetcher.formDatafor optimistic UI without extra work.onSuccess/onErrorwithoutuseEffectboilerplate.Reference implementation
I've been experimenting with this pattern and captured it in a working implementation:
react-router-typed-actions. It's ~3 KB, has no dependencies beyond SuperJSON, and works with both client and server actions.I'd love feedback on:
defineAction/resolveFormDatasplit feels naturalAll reactions