Topic 14.6
React + TypeScript Patterns
In one line
The patterns that make React code hard to misuse: props that are a union so impossible combinations don't compile, variants checked for completeness, a Remote<T> type for loading/error/success, validation where server data enters, and React 19's ref as a normal prop.
Think of it like this
A light switch plate with a two-prong plug and a three-prong plug, each shaped so it only fits its own socket. You can't plug the kettle into the oven socket even if you push hard. Good types make wrong combinations not fit. A component that needs href for links and onPress for buttons should have two plug shapes, not one plug with five optional pins.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Discriminated union
- A union of object types that share a field with different literal values (the tag), so checking the tag tells you which one you have.
- Discriminated props
- Props typed as a discriminated union, so each variant accepts only its own props.
- Exhaustive
- Covering every possible case. The compiler checks it using
never. - Remote<T>
- A union with three states: loading, error with a message, success with data of type
T. - Illegal state
- A combination of values that makes no sense, like loading and having an error at the same time.
- Boundary
- The place where data enters your program from outside: an API, storage, a form.
- Parse
- Check unknown data and turn it into a typed value, or fail with an error.
- forwardRef
- The older React function for letting a component receive a
ref. Not needed in React 19. - assertNever
- A helper that takes a
nevervalue. Calling it with anything else is a compile error.
Step by step
01Two plugs instead of one plug with optional pins
Write each variant as its own object type with a kind tag, and join them with |. A link has href, a button has onPress. Passing onPress to a link is an error because the link variant has no such prop.
type ActionProps =
| { kind: "link"; label: string; href: string }
| { kind: "button"; label: string; onPress: () => void };
function Action(props: ActionProps) {
if (props.kind === "link") {
return <a href={props.href}>{props.label}</a>;
}
return <button onClick={props.onPress}>{props.label}</button>;
}02Make variants check themselves
Record<Status, string> demands a value for every status. The switch with a never default demands a case for every status. Add "info" to Status and both fail until you handle it.
03Replace three booleans with one union
Remote<T> has one status field. The data exists only when it is "success". A component that handles the three cases cannot forget one: add a default that calls assertNever.
04Validate where the data comes in
The loader's job ends with a User, not any. Accept unknown, check, return. Then useRemote<User> and the component are typed all the way down without a single cast.
function parseUser(raw: unknown): User {
if (
typeof raw === "object" && raw !== null &&
"id" in raw && "name" in raw &&
typeof raw.id === "number" && typeof raw.name === "string"
) {
return { id: raw.id, name: raw.name };
}
throw new Error("The server sent something that is not a User");
}05ref as a prop (React 19)
Take ref like any other prop from ComponentProps<"input"> and hand it to the element. No forwardRef, no extra generic.
The parent writes const ref = useRef<HTMLInputElement>(null) and <Field ref={ref} />. Hover the type of the ref prop in the third example.
type FieldProps = ComponentProps<"input"> & { label: string };
function Field({ label, ref, ...rest }: FieldProps) {
return <label>{label} <input ref={ref} {...rest} /></label>;
}Try it yourself
- 1
Add a variant
In the first example add
"info"toStatus. Predict how many places complain (thetonesobject and theswitch), then fix both. Notice you did not have to search for them. - 2
Make an impossible combination
Try
<Action kind="button" label="x" href="/x" onPress={() => {}} />. Neither variant has both props. Read how the error describes the nearest variant. - 3
Poison the server answer
In the second example change the fake answer to
{ id: "7", name: "Asha" }and watch the Console: the parser throws,useRemotecatches it asunknown, and the component showsFailed: .... Now replaceparseUser(raw)withraw as User: the page happily showsHello, Ashaalthoughidis a string, and nothing warns you. The cast made the wrong data look right.
Code & diagrams
The third Action is a link given a button's prop, which no variant allows. The error mentions only the link variant. The page still draws an anchor without an href: the bug the error predicted.
Preview
Console
Nothing logged yet.Expected console output
<div><a href="/docs">Docs</a><button>Save</button><a>Broken</a><p>amber alert</p></div>T flows from fetchUser's return type into user.data, with no cast. The fake server answers after 50 ms; the Console shows Loading... then the greeting. Change the answer to { id: "7", name: "Asha" } and the parser rejects it.
Preview
Console
Nothing logged yet.Expected console output
Loading...
Hello, AshaField is a plain function that takes ref from its props and passes it on; no forwardRef. The script clicks the button and prints which input got focus.
Preview
Console
Nothing logged yet.Expected console output
AshaBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Add a status and forget to handle it
In the first example, change type Status = "ok" | "warn" | "error"; to type Status = "ok" | "warn" | "error" | "info";.
Break #2
Remove a case from the loading component
In the second example, delete the case "error": line and the return <p>Failed: ...</p>; line below it.
Break #3
The server sends the wrong shape
In the second example, change the fake answer { id: 7, name: "Asha" } to { id: "7", name: "Asha" }.
Myth vs fact
Myth
Optional props are the easy way to support variants.
Fact
They allow impossible combinations and force ! or checks inside. A discriminated union gives each variant exactly its own props.
Myth
response.json() as User is fine, it's typed.
Fact
It's a cast. The compiler believes you; the server may not agree. Parse unknown at the boundary.
Myth
You need forwardRef to pass refs to your component.
Fact
Not in React 19. ref is a normal prop on function components, and forwardRef is only for older React versions and old code.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep the whole
propsobject while you narrow a discriminated union. Destructuring only the tag (const { kind } = props) still letsif (kind === "link")narrowprops.href, but destructuring a variant-only field up front, or collecting the rest with...rest, makes the compiler lose the connection and reportProperty 'href' does not exist. - ▸
A common trick for "either A or B, never both" without a tag:
{ href: string; onPress?: never } | { onPress: () => void; href?: never }. Theneveroptional props forbid the other variant's key. A literalkindtag is clearer and gives better messages. - ▸
Remote<T>is the idea behind libraries like TanStack Query (isPending/isError/isSuccessare discriminants that narrowdata). When you adopt one, keep reading its result as a union instead of destructuringdataearly, or you lose the narrowing. - ▸
React 19's
refcan also be a callback ref, and React 19 lets a callback ref return a cleanup function.Ref<T>covers both:RefCallback<T> | RefObject<T | null> | null. In your wrapper just pass it through and let TypeScript keep the type.
Remember this
- 1
The problem this solves: props that depend on each other, or a loading state that can contradict itself, let impossible combinations compile and then fail on screen. Union types make the wrong combinations impossible to write. Discriminated props. A component that takes
href?: stringandonPress?: () => void, both optional, allows<Action />with neither and<Action href="/x" onPress={...} />with both. Neither makes sense. Instead make the props a union with a literal tag (Topic 4.5):{ kind: "link"; href: string } | { kind: "button"; onPress: () => void }. Now each variant has exactly the props it needs, and inside the componentif (props.kind === "link")narrows to the right set. Keeppropswhole while you narrow:const { kind, href } = propsis an error becausehrefdoes not exist on every variant (TS2339), and a rest pattern (({ kind, ...rest })) loses the link betweenkindand the other fields. Checkprops.kindfirst, then destructure inside the branch. - 2
Exhaustive variants. When a prop is a set of words (
status: "ok" | "warn" | "error"), two tools keep every place up to date. ARecord<Status, string>object (tones) must have every key, so adding"info"to the union makes the object an error until you add its entry. Aswitchwith anassertNeverdefault (Topic 4.6) does the same for logic. Adding a variant becomes a guided to-do list from the compiler, not a hunt through the code. - 3
Data fetching states. The classic bug is three separate pieces of state,
loading,error,data, that can disagree (loading: false,error: null,data: undefined). Replace them with one union:Remote<T> = { status: "loading" } | { status: "error"; message: string } | { status: "success"; data: T }. Nowdataonly exists whenstatusis"success",messageonly when it is"error", and "no data and no error and not loading" can't be written. This is "make illegal states unrepresentable" (Topic 10.5) applied to the screen. - 4
A reusable hook returns that union:
useRemote<T>(load: () => Promise<T>): Remote<T>. The type parameter flows from the loader's return type to thedatafield, so the component using it gets the right type without a cast. Two details: the effect uses acancelledflag so an old request can't overwrite a newer one, and a rejection is typedunknown(Topic 13.3), so you checkerror instanceof Errorbefore reading.message. - 5
Type server data at the boundary. A function
fetchUser(): Promise<User>that doesreturn response.json()compiles, becausejson()returnsany, and nothing guarantees the server sent aUser. The honest way is to take the data asunknownand parse it with a function that checks the shape and returnsUseror throws (parseUser(raw: unknown): User). Inside it,"id" in rawandtypeof raw.id === "number"narrow step by step (Topic 4.4). Past that function, everything is trusted for real. In larger apps this function is generated by a schema library (Topic 13.4). Never writeas Useron data from outside: it is a lie that fails at runtime far from its cause. - 6
**React 19:
refis a normal prop.** Before React 19, a function component couldn't receiveref, so wrappers neededforwardRef, a function with a clumsy generic signature. In React 19 you writefunction Field({ ref, ...rest }: ComponentProps<"input">):refis in the props type (Ref<HTMLInputElement> | undefined), you pass it to the inner element, and the parent usesuseRef<HTMLInputElement>(null)as usual.forwardRefstill works for older code but is no longer needed for new components. - 7
One more habit: let types derive from data you already have.
type Size = (typeof sizes)[number](Topic 8.2, Topic 8.3) keeps the list of options and the type in sync,ComponentProps<typeof Card>gets a component's props without exporting them, andReturnType<typeof useRemote<User>>reads a hook's result. The less you write by hand, the less can drift.
Explain it without notes
How do you type a component whose props change depending on a variant prop?
How do you make adding a new variant fail to compile in every place that must handle it?
How would you type the state of a data-fetching component, and why is it better than separate loading, error, data values?
Why is fetch(...).then(r => r.json()) as Promise<User> risky, and what do you do instead?
Practice
Write Alert with a Status = "info" | "success" | "error" prop that maps each status to a CSS class using Record<Status, string>. Show what happens when you add "warning" to Status.
Write a Result<T> type for a form submission (idle, submitting, failed with a message, done with a value of type T) and a SubmitStatus<T> component that renders each state, using assertNever.
Trade-offs
- ↔
Discriminated props give exact checks but make the props type longer, and the error messages name only the closest variant. For a component with a single mode, a plain props type is simpler.
- ↔
Validating every server response costs code (or a schema library) and a little runtime. It is the only way for the types to be true, so do it at the few places where data enters, not everywhere.
Done when you can
I can turn optional props that depend on each other into a discriminated union.
I can make a
Recordand aswitchcheck that every variant is handled.I can model loading, error and success as one
Remote<T>type.I can explain why
as Useron fetched data is a lie and parseunknowninstead.I can write a wrapper that takes
refas a prop in React 19.