Skip to the chapter
←ReactGGroundwork⚛React
React/Intermediate/I16
27 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛I16 · Part 2 · Intermediate

TypeScript with React

Props are a function's parameters, so typing a component is typing a function.

5 min read17 sections2 exercises
27 of 57

In this chapter

  • Start here
  • The types you will use daily
  • Extending a DOM element
  • Refs are props now
  • When your prop clashes with the element's

and 12 more

about 5 min
17On this page
  1. Start here
  2. The types you will use daily
  3. Extending a DOM element
  4. Refs are props now
  5. When your prop clashes with the element's
  6. Defaults make optional props required inside
  7. useState needs help only sometimes
  8. Refs
  9. What changed in React 19's types
  10. Discriminated unions make impossible states impossible
  11. Typing a reducer
  12. Generic components
  13. What children can and cannot promise
  14. Events
  15. Context without the null dance
  16. Data from outside is unknown
  17. Two habits worth forming

Start here

type ButtonProps = {
  label: string;
  onClick: () => void;
  variant?: "primary" | "ghost";      // optional, and only these two
  children?: React.ReactNode;
};

function Button({ label, onClick, variant = "primary" }: ButtonProps) { ... }

No React.FC. It adds nothing useful, makes generics awkward, and historically implied a children prop the component may not accept. Annotate the parameter — that is the whole pattern.

type or interface for props? Either works. Pick one for the codebase; type is the more common choice because unions and intersections, which props need constantly, only work with it.

The types you will use daily

TypeFor
React.ReactNodeAnything renderable — the right type for children
React.ReactElementSpecifically a JSX element, not a string or null
React.ComponentProps<"button">Every prop a real <button> takes
React.ChangeEvent<HTMLInputElement>An input's change event
React.FormEvent<HTMLFormElement>A form submit
React.MouseEvent<HTMLButtonElement>, React.KeyboardEventClicks and key presses
React.CSSPropertiesA style object
React.JSX.ElementWhat a component returns, when you annotate it

Extending a DOM element

type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "ghost";
};

function Button({ variant = "primary", ...rest }: ButtonProps) {
  return <button className={"btn btn--" + variant} {...rest} />;
}

Now type, disabled, aria-label, onClick and every other button attribute are typed correctly and autocompleted, without listing one of them. This is the single most useful React-specific TypeScript pattern.

Refs are props now

function TextInput(props: React.ComponentProps<"input">) {
  return <input className="field" {...props} />;   // ref arrives inside props
}

const ref = useRef<HTMLInputElement>(null);
<TextInput ref={ref} />                             // ✓ no forwardRef

Since React 19 a function component receives ref as an ordinary prop, and ComponentProps<"input"> already includes it. If you need to be explicit, ComponentPropsWithRef and ComponentPropsWithoutRef say which one you mean — the second is right for a wrapper that must not pass a ref through.

When your prop clashes with the element's

type InputProps = Omit<React.ComponentProps<"input">, "size"> & {
  size?: "sm" | "md" | "lg";         // the DOM's size is a number of characters
};

function Input({ size = "md", ...rest }: InputProps) {
  return <input className={"input input--" + size} {...rest} />;
}

Intersecting two types that both define size does not replace one with the other; it demands a value that is a number and a string at once, which nothing is. Omit removes the element's version first. size, color, type and onChange are the usual collisions.

Defaults make optional props required inside

variant?: "primary" | "ghost" means the caller may leave it out. Inside the component, after variant = "primary" in the parameter list, TypeScript knows it is always defined, so there is no undefined check to write. That is why defaults belong in the destructuring rather than in an if further down: the type and the behaviour are declared in the same place.

useState needs help only sometimes

const [count, setCount] = useState(0);              // inferred: number
const [user, setUser] = useState<User | null>(null); // otherwise: always null
const [items, setItems] = useState<Todo[]>([]);      // otherwise: never[]

Inference is right whenever the initial value is representative. It is wrong for null and for an empty array, which are the two cases worth remembering.

Refs

const input = useRef<HTMLInputElement>(null);
input.current?.focus();                       // null until mounted

const timer = useRef<number | undefined>(undefined);   // a mutable box

The optional chaining is not defensive noise — a DOM ref genuinely is null during the first render, and the type says so. Since React 19, useRef requires an argument, and every ref object is mutable.

What changed in React 19's types

BeforeNow
useRef() with no argumentCompile error — pass undefined or null
ref={(el) => (node = el)}Error: a ref callback may now return a cleanup function, so an implicit return is rejected. Use braces.
Global JSX namespaceReact.JSX
ReactElement["props"] was anyNow unknown unless you pass a type argument
useReducer<React.Reducer<S, A>>(reducer)useReducer(reducer) — let it infer
npx types-react-codemod@latest preset-19 ./src     // fixes most of these mechanically

Discriminated unions make impossible states impossible

type State =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "ready"; data: User };

if (state.status === "ready") {
  state.data.name;        // ✓ narrowed — data exists here
  state.error;            // ✗ compile error — and rightly so
}

This is the type-level version of the reducer argument, and it is where TypeScript earns its place in a React codebase: not catching typos, but making a whole class of UI bug unrepresentable.

Typing a reducer

type Action =
  | { type: "added"; text: string }
  | { type: "removed"; id: string }
  | { type: "cleared" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "added":   return { ...state, items: [...state.items, action.text] };
    case "removed": return { ...state, items: state.items.filter((i) => i.id !== action.id) };
    case "cleared": return { ...state, items: [] };
  }
}

With no default case, adding a fourth action type makes the function fail to return on one path — a compile error pointing exactly at the reducer you forgot to update. That exhaustiveness check is free and genuinely prevents bugs. Inside each case, action is narrowed, so action.id only exists where the type has one.

Generic components

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  keyOf: (item: T) => string;
};

function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
  return <ul>{items.map((i) => <li key={keyOf(i)}>{renderItem(i)}</li>)}</ul>;
}

<List items={users} keyOf={(u) => u.id} renderItem={(u) => u.name} />
// u is inferred as User — no annotation needed at the call site

What children can and cannot promise

children: React.ReactNode;                          // anything renderable
children: React.ReactElement;                       // exactly one element, not text
children: (state: { open: boolean }) => React.ReactNode;   // a render function

One limit surprises people: you cannot type children as "only <Tab> elements". Every JSX expression has the same type, React.JSX.Element, whatever component produced it, so <Tabs><div /></Tabs> compiles even when the prop looks strict. If the structure matters, enforce it with an API instead — an items array prop, or compound components that read shared context.

Events

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  setValue(e.target.value);           // typed as string
}

<input onChange={(e) => setValue(e.target.value)} />
// inline: e is inferred from the JSX attribute — no annotation needed

Inline handlers are inferred from context. You only annotate when the function is defined separately, which is worth knowing before you decorate every arrow with a type.

For your own components, type callback props by what they mean, not by the DOM event that triggered them: onSelect: (id: string) => void rather than onSelect: (e: React.MouseEvent) => void. The parent wants the id; making it dig through an event couples it to your markup.

Context without the null dance

const AuthContext = createContext<Auth | null>(null);

export function useAuth(): Auth {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx;                          // narrowed — callers get Auth
}

One runtime check in one place, and every consumer gets a non-null type. The alternative — optional chaining at forty call sites — is worse in every way.

Data from outside is unknown

const res = await fetch("/api/user");
const user = (await res.json()) as User;    // ✗ a promise to the compiler, not a check

const UserSchema = z.object({ id: z.string(), name: z.string() });
type User = z.infer<typeof UserSchema>;
const user = UserSchema.parse(await res.json());   // ✓ checked at runtime, typed after

TypeScript types vanish when the code runs, so an API that returns a different shape passes an as cast silently and fails later, far from the cause. Parsing at the boundary turns that into one clear error where the data enters, and the schema doubles as the type.

Two habits worth forming

  • Avoid any; prefer unknown. any switches the checker off and the hole spreads through everything it touches. unknown forces you to narrow before use.
  • Derive types rather than restating them. type FormValues = z.infer<typeof schema>, or Pick<User, "id" | "name">. A type written twice is a type that will disagree with itself.
Interview answer, one sentence

"Typing a component is typing a function's parameter, so I annotate props and skip React.FC, and I extend ComponentProps<'button'> rather than restating DOM attributes — which since React 19 includes ref. The real payoff is discriminated unions for state — narrowing means the compiler stops you reading data in the error branch."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 11 testsHandle every branch of a unionintermediate
  • 5 testsBranch on the prop that describes what state you are inintermediate
All 66 for this level →
I16

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. I15Previous · I15Testing React
  2. I16you are here
  3. Next · I17Animation and transitionsCSS first, a library when you need interruption, and the one case React makes hard.6 min read

01On this page

17 sections · 0%

  1. 01Start here
  2. 02The types you will use daily
  3. 03Extending a DOM element
  4. 04Refs are props now
  5. 05When your prop clashes with the element's
  6. 06Defaults make optional props required inside
  7. 07useState needs help only sometimes
  8. 08Refs
  9. 09What changed in React 19's types
  10. 10Discriminated unions make impossible states impossible
  11. 11Typing a reducer
  12. 12Generic components
  13. 13What children can and cannot promise
  14. 14Events
  15. 15Context without the null dance
  16. 16Data from outside is unknown
  17. 17Two habits worth forming

[] chapters · t top