Skip to content

TypeScript with React

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:

src/proofs/ts/ComponentPropsGood.tsx
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.

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.

These three look interchangeable and are not. ReactNode is a big union — anything renderable, including null:

src/proofs/ts/NodeVsElement.tsx
// 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 it
import 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 namespace
interface 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 null
import 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
Which return/children type actually accepts what

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:

src/proofs/ts/GenericList.tsx
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 .tsx
const 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 `&rbrace;`?
BadArrowGeneric.tsx(1,51): error TS1382: Unexpected token. Did you mean `{'>'}` or `&gt;`?
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 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:

src/proofs/ts/Satisfies.tsx
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.

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:

src/proofs/ts/DiscriminatedUnion.tsx
// 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 typing
import { 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.
src/proofs/ts/UseToggle.tsx
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;

Inference handles most hooks, but supply a type argument when the initial value doesn’t pin it down:

const [count, setCount] = useState(0); // inferred: number
const [user, setUser] = useState<User | null>(null); // needs the annotation

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.MouseEvent

React 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 declarations
function 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 overload
import { 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:

src/proofs/ts/UseRefFix.tsx
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;

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.”

ChoiceBenefitCost
ComponentProps<'button'>Every host prop, zero re-declarationIncludes ref — use ComponentPropsWithoutRef if your wrapper needs its own
ReactNode as a return/children typeAccepts null, strings, arrays — matches what most components actually returnToo broad when a signature should promise “always a real element”
satisfies over a plain type annotationKeeps per-property literal/narrow types while still validating the shapeOne more piece of syntax to know; easy to reach for a plain annotation out of habit
Discriminated union props + exhaustive switchCompiler-enforced handling of every variantMore type declarations up front than one shape with optional fields
ref as a plain prop (React 19) vs. forwardRefNo generic wrapper, one less layerOlder code and libraries you depend on may still use forwardRef — both coexist
  • “ReactElement and JSX.Element are different types.” They’re not — @types/react defines JSX.Element as interface Element extends ReactElement<any, any> {}. Whichever you pick, both reject null/undefined/plain strings the same way.
  • “useRef<T>() with no argument still works, it just infers undefined.” Not in @types/react@19 — there is no zero-argument overload anymore. tsc fails immediately with Expected 1 arguments, but got 0, not a silent wrong inference.
  • “<T> always works for a generic component in .tsx.” Only in a plain function declaration. 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.
  • “satisfies is 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.
  • Does a prop typed any show up anywhere it could instead be a real shape, unknown, or a generic? any silently disables every check this lesson relies on — it isn’t a neutral placeholder.
  • Is a component’s return type annotated JSX.Element (or ReactElement) in a place that also needs to return null (an early “nothing to render” branch)? That combination fails to compile — check whether the annotation should be ReactNode instead, or whether the null branch is a real bug.
  • Does useRef<HTMLElement>() appear with no argument? Under @types/react@19 that’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 own ref prop separately? Check whether ComponentPropsWithoutRef was the type that was actually needed, to avoid two conflicting ref sources.
  • 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.

Does ComponentProps<'button'> include a ref key, verified with a type-level tsc check?
type ButtonProps = ComponentProps<'button'> & { icon: React.ReactNode };
type ButtonPropsNoRef = ComponentPropsWithoutRef<'button'>;
A function is typed to return JSX.Element. One branch needs to return null. What happens?
Why does this fail to parse in a .tsx file?
const ArrowList = <T>({ items }: { items: T[] }) => <ul>{items.length}</ul>;
What is the React 19 change to useRef's type signature, verified against @types/react's actual declarations?
Why does a custom hook returning [value, setter] need as const?
Why does .push type-check here but would NOT on the equivalent : Record<string, number | number[]> annotation?
const config = { a: 1, b: [1,2,3] } satisfies Record<string, number | number[]>;
config.b.push(4);