Command Palette

Search for a command to run...

Hectal
PHASE 14Intermediate ~25 min· topic 6 of 6

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 never value. 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.

Action.tsxwhole filetsx
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.

Replace three booleans with one uniondiagram
Rendering diagram…

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.

parseUser.tswhole filets
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.

Field.tsxwhole filetsx
type FieldProps = ComponentProps<"input"> & { label: string };

function Field({ label, ref, ...rest }: FieldProps) {
  return <label>{label} <input ref={ref} {...rest} /></label>;
}

Try it yourself

  1. 1

    Add a variant

    In the first example add "info" to Status. Predict how many places complain (the tones object and the switch), then fix both. Notice you did not have to search for them.

  2. 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. 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, useRemote catches it as unknown, and the component shows Failed: .... Now replace parseUser(raw) with raw as User: the page happily shows Hello, Asha although id is a string, and nothing warns you. The cast made the wrong data look right.

Code & diagrams

Discriminated props and exhaustive variants

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

<div><a href="/docs">Docs</a><button>Save</button><a>Broken</a><p>amber alert</p></div>
Remote<T>, a typed loading hook and a parser at the boundary

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Loading...
Hello, Asha
React 19: ref is just a prop

Field 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Asha

Break 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";.

terminal
$ Types panel
── what you'll see ──
index.tsx(16,7): error TS2741: Property 'info' is missing in type '{ ok: string; warn: string; error: string; }' but required in type 'Record<Status, string>'.
index.tsx(31,13): error TS2322: Type '"info"' is not assignable to type 'never'.

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.

terminal
$ Types panel
── what you'll see ──
index.tsx(61,26): error TS2345: Argument of type '{ status: "error"; message: string; }' is not assignable to parameter of type 'never'.

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" }.

terminal
$ Console
── what you'll see ──
Loading...
Failed: The server sent something that is not a User

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 props object while you narrow a discriminated union. Destructuring only the tag (const { kind } = props) still lets if (kind === "link") narrow props.href, but destructuring a variant-only field up front, or collecting the rest with ...rest, makes the compiler lose the connection and report Property '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 }. The never optional props forbid the other variant's key. A literal kind tag is clearer and gives better messages.

  • ▸

    Remote<T> is the idea behind libraries like TanStack Query (isPending/isError/isSuccess are discriminants that narrow data). When you adopt one, keep reading its result as a union instead of destructuring data early, or you lose the narrowing.

  • ▸

    React 19's ref can 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. 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?: string and onPress?: () => 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 component if (props.kind === "link") narrows to the right set. Keep props whole while you narrow: const { kind, href } = props is an error because href does not exist on every variant (TS2339), and a rest pattern (({ kind, ...rest })) loses the link between kind and the other fields. Check props.kind first, then destructure inside the branch.

  2. 2

    Exhaustive variants. When a prop is a set of words (status: "ok" | "warn" | "error"), two tools keep every place up to date. A Record<Status, string> object (tones) must have every key, so adding "info" to the union makes the object an error until you add its entry. A switch with an assertNever default (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. 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 }. Now data only exists when status is "success", message only 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. 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 the data field, so the component using it gets the right type without a cast. Two details: the effect uses a cancelled flag so an old request can't overwrite a newer one, and a rejection is typed unknown (Topic 13.3), so you check error instanceof Error before reading .message.

  5. 5

    Type server data at the boundary. A function fetchUser(): Promise<User> that does return response.json() compiles, because json() returns any, and nothing guarantees the server sent a User. The honest way is to take the data as unknown and parse it with a function that checks the shape and returns User or throws (parseUser(raw: unknown): User). Inside it, "id" in raw and typeof 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 write as User on data from outside: it is a lie that fails at runtime far from its cause.

  6. 6

    **React 19: ref is a normal prop.** Before React 19, a function component couldn't receive ref, so wrappers needed forwardRef, a function with a clumsy generic signature. In React 19 you write function Field({ ref, ...rest }: ComponentProps<"input">): ref is in the props type (Ref<HTMLInputElement> | undefined), you pass it to the inner element, and the parent uses useRef<HTMLInputElement>(null) as usual. forwardRef still works for older code but is no longer needed for new components.

  7. 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, and ReturnType<typeof useRemote<User>> reads a hook's result. The less you write by hand, the less can drift.

Explain it without notes

01

How do you type a component whose props change depending on a variant prop?

02

How do you make adding a new variant fail to compile in every place that must handle it?

03

How would you type the state of a data-fetching component, and why is it better than separate loading, error, data values?

04

Why is fetch(...).then(r => r.json()) as Promise<User> risky, and what do you do instead?

Practice

01

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.

02

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 Record and a switch check that every variant is handled.

  • I can model loading, error and success as one Remote<T> type.

  • I can explain why as User on fetched data is a lie and parse unknown instead.

  • I can write a wrapper that takes ref as a prop in React 19.