TypeScript with React
Typing a component and its props
Section titled “Typing a component and its props”A React component is a function, so you type it by typing its props. Define a type (or interface) for the props and annotate the parameter:
type ButtonProps = { label: string; onClick: () => void; variant?: 'primary' | 'secondary'; // optional, a literal union};
function Button({ label, onClick, variant = 'primary' }: ButtonProps) { return <button className={variant} onClick={onClick}>{label}</button>;}Prefer typing the props object directly (as above) over the older React.FC — React.FC has fallen out of favor (it complicates generics and children typing). Just annotate the destructured props parameter.
Stealing a host element’s props: ComponentProps vs ComponentPropsWithoutRef
Section titled “Stealing a host element’s props: ComponentProps vs ComponentPropsWithoutRef”A wrapper component often needs to accept every prop the host element it renders accepts — instead of re-declaring onClick, disabled, aria-*, and so on by hand, “steal” them:
import type { ComponentProps, ComponentPropsWithoutRef } from 'react';
// ComponentProps<'button'> "steals" every prop the host <button> element// accepts, including `ref` (a plain prop for host elements) and all the// standard HTML attributes — so a wrapper takes exactly what a real// <button> takes, with no manual re-declaration.type IconButtonProps = ComponentProps<'button'> & { icon: React.ReactNode };
function IconButton({ icon, children, ...rest }: IconButtonProps) { return <button {...rest}>{icon}{children}</button>;}
// Type-level proof, checked by tsc: does 'ref' appear in each type's keys?type HasRefKey<T> = 'ref' extends keyof T ? true : false;
// ComponentProps<'button'> DOES include 'ref' (host elements accept it directly).const includesRef: HasRefKey<ComponentProps<'button'>> = true;// ComponentPropsWithoutRef<'button'> strips it — for when you attach your own ref.const excludesRef: HasRefKey<ComponentPropsWithoutRef<'button'>> = false;
void includesRef;void excludesRef;void IconButton;That last pair of lines is a real, tsc-checked assertion, not a comment claiming it — flip either literal (true/false) and tsc fails with Type 'true' is not assignable to type 'false'. Reach for ComponentPropsWithoutRef<'button'> specifically when your wrapper attaches its own ref to a different inner element than the one it forwards ...rest to — ComponentProps would otherwise let two different ref props collide.
Children and common React types
Section titled “Children and common React types”When a component wraps other content, type children as React.ReactNode — the broadest “anything renderable” type:
type CardProps = { title: string; children: React.ReactNode; // JSX, strings, numbers, arrays, null…};
function Card({ title, children }: CardProps) { return <section><h2>{title}</h2>{children}</section>;}Other types you’ll reach for: React.ComponentProps<'button'> (above), React.CSSProperties (a style object), and — the one people mix up most — ReactNode vs ReactElement vs JSX.Element, next.
ReactNode vs ReactElement vs JSX.Element
Section titled “ReactNode vs ReactElement vs JSX.Element”These three look interchangeable and are not. ReactNode is a big union — anything renderable, including null:
// ReactNode is the broadest "anything renderable" type: elements, strings,// numbers, booleans, null, undefined, arrays/iterables of the same.function Maybe({ show }: { show: boolean }): React.ReactNode { if (!show) return null; // ReactNode includes null — fine return 'hello'; // ReactNode includes plain strings — fine}
void Maybe;ReactElement is narrower — only an actual element, no null, no plain strings:
// @expect-error ReactElement (and JSX.Element) reject null — only ReactNode includes itimport type { ReactElement } from 'react';
// ReactElement does NOT include null/undefined/string/number/boolean —// only an actual element.function Bad({ show }: { show: boolean }): ReactElement { if (!show) return null; // must error: null is not assignable to ReactElement return <p>hi</p>;}BadElement.tsx(6,14): error TS2322: Type 'null' is not assignable to type'ReactElement<unknown, string | JSXElementConstructor<any>>'.And JSX.Element? It behaves identically to ReactElement<any, any> — @types/react literally declares it that way:
// from @types/react's global JSX namespaceinterface Element extends React.ReactElement<any, any> {}So JSX.Element also rejects null, for the same reason. There’s a real, current wrinkle worth knowing, though: with @types/react@19, the global JSX namespace is no longer ambient by default — a bare JSX.Element return type can fail with Cannot find namespace 'JSX' unless you import it:
// @expect-error even with the JSX import fixed, ReactElement/JSX.Element still reject nullimport type { JSX } from 'react';
function AlsoBad({ show }: { show: boolean }): JSX.Element { if (!show) return null; // must error, same as ReactElement above return <p>hi</p>;}_import-jsx.tsx(4,14): error TS2322: Type 'null' is not assignable to type 'Element'.(Without the import type { JSX } from 'react' line, this file fails to compile at all — TS2503: Cannot find namespace 'JSX' — a different, earlier error than the one above. React.JSX.Element works with no import, if you’d rather qualify it than import the name.) The rule of thumb: return ReactNode (or ReactNode | null explicitly) when null/conditional-nothing is a legitimate result — which is most components — and reserve ReactElement/JSX.Element for a signature that specifically promises “always a real element, never nothing.”
flowchart LR node["ReactNode (strings, numbers, null, undefined, arrays, elements)"] elem["ReactElement === JSX.Element (an element only, never null)"] node -->|"ReactElement is one kind of ReactNode"| elem
Generic components: the <T,> trailing comma
Section titled “Generic components: the <T,> trailing comma”A plain function declaration has no ambiguity with a type parameter in a .tsx file:
function List<T>({ items, render }: { items: T[]; render: (item: T) => React.ReactNode }) { return <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>;}
void List;An arrow-function generic component needs the trailing comma, because .tsx parses <T> as the start of a JSX tag otherwise:
const ArrowList = <T,>({ items, render }: { items: T[]; render: (item: T) => React.ReactNode }) => ( <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>);Drop the comma and watch it actually happen — real, cascading parser errors, not a style nitpick:
// @expect-error missing trailing comma — <T> is parsed as an unclosed JSX tag in .tsxconst ArrowList = <T>({ items }: { items: T[] }) => <ul>{items.length}</ul>;BadArrowGeneric.tsx(1,20): error TS17008: JSX element 'T' has no corresponding closing tag.BadArrowGeneric.tsx(1,41): error TS1005: '}' expected.BadArrowGeneric.tsx(1,47): error TS1381: Unexpected token. Did you mean `{'}'}` or `}`?BadArrowGeneric.tsx(1,51): error TS1382: Unexpected token. Did you mean `{'>'}` or `>`?BadArrowGeneric.tsx(3,1): error TS1005: '</' expected.<T,> (or <T extends unknown>, which also disambiguates) fixes it. Plain function declarations don’t need either trick.
satisfies: validate without widening
Section titled “satisfies: validate without widening”satisfies checks an object literal against a shape without changing the literal’s inferred type to that shape. The difference shows up the moment you access a property whose value is more specific than the checked type allows:
type Status = 'idle' | 'loading' | 'error';
// `satisfies` checks the object against a shape WITHOUT widening the// variable's type TO that shape — each property keeps its own more-specific// inferred type instead of the annotated union.const retryDelayMs = { idle: 0, loading: 250, error: [500, 1000, 2000], // per-attempt backoff, not a single number} satisfies Record<Status, number | number[]>;
// Proof: retryDelayMs.error is still typed as `number[]` specifically (not// widened to `number | number[]`), so array methods work with no narrowing.retryDelayMs.error.push(4000);
// With a plain annotation instead, every value is widened to the union —// `.push` is not available on `number | number[]` without narrowing first.const retryDelayMs2: Record<Status, number | number[]> = { idle: 0, loading: 250, error: [500, 1000, 2000],};// @ts-expect-error — retryDelayMs2.error's type is `number | number[]`// (widened by the annotation), and `.push` doesn't exist on `number`.retryDelayMs2.error.push(4000);Both satisfies and a plain annotation still catch a typo’d key or a value of the wrong overall type at the object literal — satisfies just doesn’t also throw away precision you already had.
Discriminated union props
Section titled “Discriminated union props”When a prop type has genuinely different shapes depending on a “kind” field, model it as a union and let TypeScript narrow per branch — instead of one shape with a pile of optional fields:
// A discriminated union props type: exactly one shape per `status`, and TS// narrows which OTHER fields exist based on which literal `status` is.type FetchState = | { status: 'idle' } | { status: 'loading' } | { status: 'error'; message: string } | { status: 'success'; data: string[] };
function Result({ state }: { state: FetchState }) { switch (state.status) { case 'idle': return null; case 'loading': return <p>Loading…</p>; case 'error': // Narrowed: only this branch has `message` — no optional-chaining needed. return <p role="alert">{state.message}</p>; case 'success': // Narrowed: only this branch has `data`. return <ul>{state.data.map((d) => <li key={d}>{d}</li>)}</ul>; default: { // Exhaustiveness check: if a FetchState variant is ever added without a // matching case above, `state` here is NOT `never` and this line fails // to compile — catching the missing branch at build time. const exhaustive: never = state; return exhaustive; } }}
void Result;The never-assignment in default isn’t decoration — add a fifth { status: 'cancelled' } variant to the union without adding its case, and tsc fails right there:
error TS2322: Type '{ status: "cancelled"; }' is not assignable to type 'never'.That’s a compile-time guarantee every branch of a discriminated union prop is handled, checked on every build — not something you have to remember to test.
as const for tuples returned from a custom hook
Section titled “as const for tuples returned from a custom hook”A custom hook that returns [value, setter] (mirroring useState’s own shape) needs as const, or the return type collapses into an array of the union of both element types:
// @expect-error no as const — return type widens to an array of the union, losing per-position typingimport { useState } from 'react';
// No explicit return type, no `as const`: TS infers the return as an ARRAY// type (the union of all element types), not a fixed-length tuple.function useToggle(initial: boolean) { const [value, setValue] = useState(initial); function toggle() { setValue((v) => !v); } return [value, toggle]; // inferred: (boolean | (() => void))[]}
function Widget() { const [on, toggleOn] = useToggle(false); on(); // must error — `on` is typed boolean | (() => void), not narrowed by position void toggleOn; return null;}BadUseToggle.tsx(18,3): error TS2349: This expression is not callable. Not all constituents of type 'boolean | (() => void)' are callable. Type 'false' has no call signatures.import { useState } from 'react';
// `as const` on the returned array fixes both length and per-position type:// TS infers `readonly [boolean, () => void]` instead of `(boolean | (() => void))[]`.function useToggle(initial: boolean) { const [value, setValue] = useState(initial); function toggle() { setValue((v) => !v); } return [value, toggle] as const;}
function Widget() { const [on, toggle] = useToggle(false); // `on` is `boolean`, `toggle` is `() => void` — exactly like useState's own // return type, and for the same reason (useState itself returns a tuple // via an explicit overload, not `as const` — but the effect is identical). return <button onClick={toggle}>{on ? 'on' : 'off'}</button>;}
void Widget;Typing hooks
Section titled “Typing hooks”Inference handles most hooks, but supply a type argument when the initial value doesn’t pin it down:
const [count, setCount] = useState(0); // inferred: numberconst [user, setUser] = useState<User | null>(null); // needs the annotationTyping events
Section titled “Typing events”Event handlers get typed event objects. Let contextual typing infer them inline where possible; annotate when you extract the handler:
function onChange(e: React.ChangeEvent<HTMLInputElement>) { console.log(e.target.value);}<button onClick={(e) => e.preventDefault()}>Go</button> // e inferred as React.MouseEventReact 19: ref is a normal prop, and useRef needs an argument
Section titled “React 19: ref is a normal prop, and useRef needs an argument”A concrete currency win: in React 19, ref is a regular prop for function components, so you no longer need forwardRef and its awkward generics. You type ref right alongside the other props:
// React 19 — ref is just a prop.type InputProps = { placeholder?: string; ref?: React.Ref<HTMLInputElement>;};
function TextInput({ placeholder, ref }: InputProps) { return <input placeholder={placeholder} ref={ref} />;}forwardRef still works and older code using React.forwardRef<HTMLInputElement, Props> is fine, but for new components in React 19 you skip it entirely — one fewer wrapper and no generic gymnastics.
The companion change is in useRef itself: @types/react@19 has exactly three overloads, and none of them takes zero arguments —
// from @types/react's actual declarationsfunction useRef<T>(initialValue: T): RefObject<T>;function useRef<T>(initialValue: T | null): RefObject<T | null>;function useRef<T>(initialValue: T | undefined): RefObject<T | undefined>;— so the old useRef<HTMLDivElement>() habit (no argument, relying on an implicit-undefined overload from older type definitions) now fails to match any of them:
// @expect-error React 19 types: useRef has no zero-argument overloadimport { useRef } from 'react';function Box() { const ref = useRef<HTMLDivElement>(); // React 19 types: no zero-arg overload return <div ref={ref} />;}BadUseRefNoArg.tsx(3,15): error TS2554: Expected 1 arguments, but got 0.The fix is exactly the fix you’d guess — pass the initial value explicitly, null for a DOM ref you’ll attach later:
import { useRef } from 'react';
function Box() { // React 19 requires an explicit initial value — pass null for a DOM ref. const ref = useRef<HTMLDivElement>(null); return <div ref={ref} />;}
void Box;Mental Model
Section titled “Mental Model”TypeScript’s job here isn’t to describe what React does at runtime — it’s to describe the shape of what you’re allowed to pass, return, and read. ReactElement/JSX.Element vs. ReactNode, ComponentProps vs. ComponentPropsWithoutRef, and the tuple-vs-array distinction from as const are all the same idea repeated: the runtime value is often identical either way, but the type narrows or widens what the compiler will let the rest of your code do with it. Read every type in this lesson as “what does this let a caller get away with,” not “what does this look like.”
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
ComponentProps<'button'> | Every host prop, zero re-declaration | Includes ref — use ComponentPropsWithoutRef if your wrapper needs its own |
ReactNode as a return/children type | Accepts null, strings, arrays — matches what most components actually return | Too broad when a signature should promise “always a real element” |
satisfies over a plain type annotation | Keeps per-property literal/narrow types while still validating the shape | One more piece of syntax to know; easy to reach for a plain annotation out of habit |
Discriminated union props + exhaustive switch | Compiler-enforced handling of every variant | More type declarations up front than one shape with optional fields |
ref as a plain prop (React 19) vs. forwardRef | No generic wrapper, one less layer | Older code and libraries you depend on may still use forwardRef — both coexist |
Common Misconceptions
Section titled “Common Misconceptions”- “
ReactElementandJSX.Elementare different types.” They’re not —@types/reactdefinesJSX.Elementasinterface Element extends ReactElement<any, any> {}. Whichever you pick, both rejectnull/undefined/plain strings the same way. - “
useRef<T>()with no argument still works, it just infersundefined.” Not in@types/react@19— there is no zero-argument overload anymore.tscfails immediately withExpected 1 arguments, but got 0, not a silent wrong inference. - “
<T>always works for a generic component in.tsx.” Only in a plainfunctiondeclaration. An arrow function needs<T,>(or<T extends unknown>) — otherwise the parser reads it as an unclosed JSX tag, with a cascade of unrelated-looking errors as the tell. - “
satisfiesis just a fancier type annotation.” It changes nothing about which errors get caught at the object literal — it only changes what type the checked expression keeps afterward. Skipping it doesn’t make your code less safe; it just widens types you might have wanted to stay specific.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a prop typed
anyshow up anywhere it could instead be a real shape,unknown, or a generic?anysilently disables every check this lesson relies on — it isn’t a neutral placeholder. - Is a component’s return type annotated
JSX.Element(orReactElement) in a place that also needs to returnnull(an early “nothing to render” branch)? That combination fails to compile — check whether the annotation should beReactNodeinstead, or whether thenullbranch is a real bug. - Does
useRef<HTMLElement>()appear with no argument? Under@types/react@19that’s a hard compile error, not a style issue — confirm the code was actually type-checked, not just visually reviewed. - Does a wrapper component spread
ComponentProps<'button'>and also declare its ownrefprop separately? Check whetherComponentPropsWithoutRefwas the type that was actually needed, to avoid two conflictingrefsources. - For a hook returning a tuple, is the return statement missing
as const(or an explicit tuple return-type annotation)? If callers destructure it and one position doesn’t behave like a function/value where expected, this is very likely why.
🐛 Spot the bug
function StatusIcon({ visible }: { visible: boolean }): JSX.Element {if (!visible) { return null;}return <span className="icon" />;}Show the answer
JSX.Element (declared in @types/react as interface Element extends ReactElement<any, any> {}) does not include null — only ReactNode does. Compiling this exact shape (return type JSX.Element, one branch returning null) fails with Type 'null' is not assignable to type 'Element', verified directly in this course’s probe. The fix is either widening the return type to React.ReactNode (correct here — nothing-to-render is a real, expected outcome) or, if the component genuinely must always return an element, restructuring the caller so it never needs the null branch. A generated component that “returns nothing” from a branch typed JSX.Element/ReactElement is one of the most common places AI-written TypeScript quietly fails to compile — always run tsc --noEmit, don’t just read the JSX.