React NotesRohit’s interview study guide
Chapter 17

TypeScript with React

Typing props, children, state, events, refs, context, generic components, defaults, higher-order components and custom hooks — for React 19.3 with TypeScript 7 and @types/react 19.3, and what changed from React 18's types.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Typing props and children

A component is a function, and its props are that function's one parameter. So typing props is just typing an object parameter: you write a type for the object and annotate the destructured parameter with it. Everything else in this chapter builds on that one idea.

The mental model that makes TypeScript + JSX click: every JSX element is a function call that TypeScript checks. <Card title="Plan" variant="info">Body</Card> is checked exactly as if you had written Card({ title: "Plan", variant: "info", children: "Body" }). Missing a required prop is a missing property; a wrong value is a type mismatch; the text between the tags is just a prop called children. Once you see JSX that way, the error messages read naturally.

From my notes: "Little bit on TypeScript & React" listed typing state, function arguments, generics in components, default props and a typed HOC. The notes had headings only for most of these (the examples were screenshots that didn't come through), so the examples in this chapter are new; the HOC example is mine and is covered in Typing a higher-order component.

Card.tsx
import type { ReactNode } from "react";

type CardProps = {
  title: string;
  subtitle?: string; // optional: string | undefined inside the component
  variant: "info" | "warning"; // a union of literal types
  children: ReactNode; // anything React can render
};

export function Card({ title, subtitle, variant, children }: CardProps) {
  return (
    <section className={`card card-${variant}`}>
      <h2>{title}</h2>
      {subtitle && <p>{subtitle}</p>}
      {children}
    </section>
  );
}
What’s happening
  1. CardProps is an ordinary object type. React adds nothing special — the same type could describe the argument of any function.
  2. subtitle?: string makes the prop optional. Inside the component its type is string | undefined, which is why the JSX guards it with subtitle && … before rendering the <p>.
  3. variant: "info" | "warning" is a union of string literals. Callers can only pass one of those two exact strings, and editors autocomplete them — a much tighter contract than string.
  4. children: ReactNode declares that the component accepts children. ReactNode is the widest "renderable" type: elements, strings, numbers, null, undefined, booleans, arrays of those, and (since React 19 types) promises of them.
  5. The destructured parameter { title, subtitle, variant, children }: CardProps is where the type is applied. Each variable gets its type from CardProps; nothing has to be annotated twice.

Now the call sites. Each element is checked like a function call against CardProps:

CardUsage.tsx
import { Card } from "./Card";

export const ok = <Card title="Plan" variant="info">Body</Card>;
export const badVariant = <Card title="Plan" variant="danger">Body</Card>; // ❌ Type '"danger"' is not assignable to type '"info" | "warning"'.
export const noTitle = <Card variant="info">Body</Card>; // ❌ Property 'title' is missing in type '{ variant: "info"; children: string; }' but required in type 'CardProps'.
export const noChildren = <Card title="Plan" variant="warning" />; // ❌ Property 'children' is missing in type '{ title: string; variant: "warning"; }' but required in type 'CardProps'.
What’s happening
  1. ok passes every required prop with valid values, and the text Body becomes children: "Body". A string is a ReactNode, so it compiles.
  2. badVariant fails with TS2322 because "danger" is not one of the literals in the union. The error points at the variant attribute, exactly like a wrong argument in a function call.
  3. noTitle fails with TS2741. Notice how the compiler describes the props it did receive — { variant: "info"; children: string; } — which shows that the text between the tags really is a children property.
  4. noChildren fails because children is required in CardProps. If you want children to be optional, write children?: ReactNode.
  5. This is the whole point of typing props: the component's contract is checked at every call site, so renaming or tightening a prop shows you every place that needs to change.

React.FC and implicit children

Many codebases type components as const Card: React.FC<CardProps> = (props) => …. That still works, but one thing changed in the React 18 types (2022): FC no longer adds children for you. Before that, every FC silently accepted children whether it rendered them or not.

NoChildren.tsx
import type { FC, PropsWithChildren } from "react";

const Title: FC<{ text: string }> = ({ text }) => <h1>{text}</h1>;
export const a = <Title text="Hi">extra</Title>; // ❌ Type '{ text: string; children: string; }' is not assignable to type 'IntrinsicAttributes & { text: string; }'.

const Box: FC<PropsWithChildren<{ padded: boolean }>> = ({ padded, children }) => (
  <div style={{ padding: padded ? 16 : 0 }}>{children}</div>
);
export const b = <Box padded>inside</Box>;
What’s happening
  1. Title is an FC<{ text: string }>. With @types/react 18 and later, FC<P> means exactly P — no hidden children.
  2. Passing extra between the tags therefore fails with TS2322, and the follow-up line of the message says "Property 'children' does not exist on type 'IntrinsicAttributes & { text: string; }'". IntrinsicAttributes is the set of props React allows on everything (just key).
  3. PropsWithChildren<P> is a helper that adds children?: ReactNode to P. It's handy when children are optional; when they're required, write children: ReactNode yourself.
  4. Under React 17 types, a would have compiled — a common source of confusion when you read older code or tutorials.

Extending native element props

A reusable Button should accept everything a real <button> does — type, disabled, aria-*, onClick — plus your own props. Don't list them by hand; take them from React's types:

Button.tsx
import type { ComponentProps } from "react";

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

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

export const save = <Button type="submit" disabled aria-label="Save" onClick={(e) => e.currentTarget.blur()} />;
export const typo = <Button colour="red" />; // ❌ Property 'colour' does not exist on type '…'. Did you mean 'color'?
What’s happening
  1. ComponentProps<"button"> is the full props type of the intrinsic <button> element: every HTML attribute, every event handler typed for HTMLButtonElement, and — in React 19 — ref, because ref is now an ordinary prop.
  2. The & intersection adds our own variant. The default "primary" is applied in the destructuring (see Default props in TypeScript).
  3. ...rest collects everything else (type, disabled, aria-label, onClick, ref…) and spreads it onto the real <button>, so the component is a transparent wrapper.
  4. In save, the inline onClick parameter e needs no annotation: it's inferred as MouseEvent<HTMLButtonElement> from the prop type, so e.currentTarget.blur() type-checks.
  5. typo fails with TS2322, and the follow-up line is "Property 'colour' does not exist on type 'IntrinsicAttributes & ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & { ...; }'. Did you mean 'color'?" — the same typo protection (and suggestion) you get on a native element. ClassAttributes is where the ref prop comes from.

ReactNode, ReactElement and JSX.Element

TypeWhat it allowsUse it for
ReactNodeanything renderable: elements, strings, numbers, null, undefined, booleans, arrays, promiseschildren and "slot" props like header, footer
ReactElementonly an element object (the result of JSX or createElement)props that must be one element, e.g. for cloneElement
React.JSX.Elementthe type of a JSX expression (ReactElement<any, any>)rarely needed; inferred return types cover it
AdvancedProps that depend on each other: discriminated unions

Sometimes props are only valid in combinations: a link needs href, a button needs onClick, and passing both makes no sense. A discriminated union of prop types expresses that, and TypeScript narrows on the discriminant inside the component:

ActionLink.tsx
type LinkProps = { kind: "link"; href: string; label: string };
type ButtonActionProps = { kind: "button"; onClick: () => void; label: string };
type ActionProps = LinkProps | ButtonActionProps;

export function Action(props: ActionProps) {
  if (props.kind === "link") {
    return <a href={props.href}>{props.label}</a>; // props is LinkProps here
  }
  return <button onClick={props.onClick}>{props.label}</button>; // props is ButtonActionProps here
}

export const link = <Action kind="link" href="/docs" label="Docs" />;
export const mixed = <Action kind="link" onClick={() => {}} label="Docs" />; // ❌ Type '{ kind: "link"; onClick: () => void; label: string; }' is not assignable to type 'IntrinsicAttributes & ActionProps'.
What’s happening
  1. ActionProps is a union of two object types that share a literal property, kind. That shared literal is the discriminant.
  2. Inside Action, props.kind === "link" narrows props to LinkProps, so props.href is available; after the if returns, only ButtonActionProps is left, so props.onClick is available.
  3. Note that the parameter is not destructured. Destructuring { kind, href } in the parameter list fails, because href doesn't exist on every member of the union. Keep props whole and narrow it.
  4. mixed says kind="link" but passes onClick instead of href. Because kind is "link", the compiler checks it against LinkProps and reports TS2322 with "Property 'onClick' does not exist on type 'IntrinsicAttributes & LinkProps'." — the type makes the invalid combination impossible to write.

#Typing state

Most of the time you don't type state at all: useState infers the state type from the initial value. useState(0) gives you number, useState("") gives string, and the setter only accepts that type (or an updater function returning it). You add a type argument only when the initial value doesn't describe every value the state will ever hold.

There are three classic cases: state that starts empty but will hold an object (null now, a User later), state that starts as an empty array (TypeScript can't guess the element type), and state that moves through several distinct phases (idle, loading, success, error), which is best modelled as a discriminated union so impossible combinations can't exist.

From my notes: "Typing State" (heading only). The examples below are new.

Profile.tsx
import { useState } from "react";

type User = { id: number; name: string };

export function Profile() {
  const [count, setCount] = useState(0); // number, inferred
  const [user, setUser] = useState<User | null>(null); // nothing yet, a User later
  const [tags, setTags] = useState<string[]>([]); // without <string[]> this would be never[]

  return (
    <div>
      <p>{user ? user.name : "Signed out"}</p>
      <p>{tags.join(", ")}</p>
      <button onClick={() => setUser({ id: 1, name: "Rohit" })}>Sign in</button>
      <button onClick={() => setTags((t) => [...t, "react"])}>Tag</button>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
    </div>
  );
}
What’s happening
  1. useState(0) has no type argument; TypeScript infers number from 0, so count is number and setCount accepts a number or (prev: number) => number.
  2. useState<User | null>(null) needs the type argument. Without it, the state would be inferred as null forever and setUser({ … }) would be an error.
  3. Because user is User | null, the JSX has to narrow it (user ? user.name : …) before reading .name. Writing user.name directly is error TS18047: "'user' is possibly 'null'."
  4. useState<string[]>([]) tells TypeScript what the array will contain. With just useState([]), the state is never[] and setTags(["react"]) fails with "Type 'string' is not assignable to type 'never'."
  5. The updater forms (t) => [...t, "react"] and (c) => c + 1 get their parameter types from the state type, so they need no annotations either.

Here are those two mistakes, as the compiler sees them:

StateMistakes.tsx
import { useState } from "react";

type User = { id: number; name: string };

export function Mistakes() {
  const [user] = useState<User | null>(null);
  const [items, setItems] = useState([]);
  setItems(["a"]); // ❌ Type 'string' is not assignable to type 'never'.
  return <p>{user.name}</p>; // ❌ 'user' is possibly 'null'.
}
What’s happening
  1. useState([]) infers the state type from the literal [], and an empty array literal with no other information is never[] — an array that can never contain anything.
  2. So setItems(["a"]) fails with TS2322: the element "a" is a string, which isn't assignable to never. The fix is useState<string[]>([]).
  3. user is User | null, and user.name is read without a check, so the compiler reports TS18047. The fix is to narrow (user?.name, if (!user) return …, or a ternary).
  4. Both errors are the compiler pointing at real runtime bugs: one would put strings into an array you'd promised was empty-typed, the other would crash on the first render when user is still null.

Modelling request state as a union

A common anti-pattern is three independent pieces of state: data, loading, error. Nothing stops loading being true while error is also set. A discriminated union makes each phase carry exactly the data it has, and a reducer with typed actions keeps the transitions honest:

requestState.ts
export type RequestState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

export type RequestAction<T> =
  | { type: "start" }
  | { type: "resolve"; data: T }
  | { type: "reject"; error: string };

export function requestReducer<T>(state: RequestState<T>, action: RequestAction<T>): RequestState<T> {
  switch (action.type) {
    case "start":
      return { status: "loading" };
    case "resolve":
      return { status: "success", data: action.data };
    case "reject":
      return { status: "error", error: action.error };
    default: {
      const unreachable: never = action; // compile error here if a new action type isn't handled
      return unreachable;
    }
  }
}
What’s happening
  1. RequestState<T> has four members, distinguished by status. Only the "success" member has data, and only "error" has error, so "loading with an error" simply can't be represented.
  2. RequestAction<T> is the same idea for actions: the type literal is the discriminant, and each action carries only its own payload.
  3. In the switch, each case narrows action: inside case "resolve", action.data exists and has type T; inside case "reject", action.error is a string.
  4. The default branch assigns action to a variable of type never. After all three cases, nothing is left, so this compiles. If someone adds { type: "cancel" } to the union and forgets a case, action is no longer never there and the build fails — an exhaustiveness check.
  5. The reducer is generic in T, so the same code works for users, products or anything else; the caller fixes T with useReducer(requestReducer<User[]>, { status: "idle" }).

Consuming it is where the union pays off — the component can't read data until it has checked the status:

UserList.tsx
import { useReducer } from "react";
import { requestReducer, type RequestState } from "./requestState";

type User = { id: number; name: string };

export function UserList({ load }: { load: () => Promise<User[]> }) {
  const initial: RequestState<User[]> = { status: "idle" };
  const [state, dispatch] = useReducer(requestReducer<User[]>, initial);

  async function handleLoad() {
    dispatch({ type: "start" });
    try {
      dispatch({ type: "resolve", data: await load() });
    } catch (e) {
      dispatch({ type: "reject", error: e instanceof Error ? e.message : String(e) });
    }
  }

  if (state.status === "loading") return <p>Loading…</p>;
  if (state.status === "error") return <p role="alert">{state.error}</p>;
  return (
    <div>
      <button onClick={handleLoad}>Load users</button>
      {state.status === "success" && (
        <ul>
          {state.data.map((u) => (
            <li key={u.id}>{u.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}
What’s happening
  1. requestReducer<User[]> is an instantiation expression (TypeScript 4.7+): it fixes T = User[] without calling the function, so useReducer infers state as RequestState<User[]> and dispatch as accepting RequestAction<User[]>.
  2. Clicking the button dispatches start (state becomes { status: "loading" }, so the component renders "Loading…"), then awaits load().
  3. On success it dispatches resolve with the array — TypeScript checks that data really is User[]. On failure, e is unknown in a catch, so it's turned into a string before dispatching reject.
  4. Each if (state.status === …) narrows state. Only inside the "error" branch can you read state.error, and only after state.status === "success" can you read state.data. Reading state.data anywhere else is a compile error.
  5. The test for this component resolves load with two users and checks both names appear, then rejects it and checks the alert shows the error message.
AdvancedPassing a setter down: Dispatch and SetStateAction

When a child receives a state setter as a prop, type it with React's own helper types rather than (value: string) => void, so the child can also use the updater form:

SetterProp.tsx
import { useState, type Dispatch, type SetStateAction } from "react";

function NameInput({ value, setValue }: { value: string; setValue: Dispatch<SetStateAction<string>> }) {
  return <input value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => setValue((v) => v.trim())} />;
}

export function Parent() {
  const [name, setName] = useState("");
  return <NameInput value={name} setValue={setName} />;
}
What’s happening
  1. SetStateAction<string> is string | ((prev: string) => string) — the two things a useState setter accepts.
  2. Dispatch<A> is (value: A) => void. Together, Dispatch<SetStateAction<string>> is exactly the type of setName.
  3. That lets NameInput call both setValue(e.target.value) and setValue((v) => v.trim()). With a hand-written (value: string) => void, the updater call would fail to compile.
  4. For a reusable component, though, prefer a narrower callback like onChange: (value: string) => void: it doesn't leak the fact that the parent stores the value in useState.

#Typing events and handlers

React's event handlers receive React's synthetic events, and @types/react has a generic type for each one: ChangeEvent<T>, MouseEvent<T>, KeyboardEvent<T>, SubmitEvent<T> and so on, where T is the element the handler is attached to. When you write the handler inline, you don't need any of them — TypeScript infers the event type from the prop (this is called contextual typing). You only need the names when the handler is a separate function.

A useful way to remember which element goes in the angle brackets: it's the type of event.currentTarget, the element the listener is on. event.target can be any descendant that started the event, which is why React's types are careful with it.

From my notes: "Typing function arguments" (heading only). For components, that mostly means two things — typing event handlers, and typing the callback props that a component calls to tell its parent something happened.

SearchForm.tsx
import { useState, type ChangeEvent, type SubmitEvent } from "react";

type SearchFormProps = {
  onSearch: (query: string) => void; // a callback prop: what the parent hears about
  placeholder?: string;
};

export function SearchForm({ onSearch, placeholder = "Search" }: SearchFormProps) {
  const [query, setQuery] = useState("");

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
  }

  function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    onSearch(query.trim());
  }

  return (
    <form onSubmit={handleSubmit}>
      <input aria-label="query" value={query} placeholder={placeholder} onChange={handleChange} />
      <button type="submit" onClick={(e) => console.log("clicked at", e.clientX)}>
        Go
      </button>
    </form>
  );
}
What’s happening
  1. onSearch: (query: string) => void is a callback prop. The parameter name query is documentation only; what matters is that the component promises to call it with a string, and that its return value is ignored (void).
  2. handleChange is a separate function, so it needs an annotation: ChangeEvent<HTMLInputElement>. That makes e.target.value a string. For an <input>, React's types know target is the input itself.
  3. handleSubmit uses SubmitEvent<HTMLFormElement>. Calling e.preventDefault() stops the browser's full-page form submission, then the trimmed query is passed to onSearch.
  4. The inline onClick={(e) => …} has no annotation at all. e is inferred as MouseEvent<HTMLButtonElement> from the onClick prop's type, so e.clientX is a number.
  5. In the test, typing " hooks " and pressing Go calls onSearch once with "hooks".

Handler types and borrowing a prop's type

Instead of annotating the parameter, you can annotate the whole function with a handler type such as ChangeEventHandler<HTMLInputElement>. And when you don't know the right name, ask the element: ComponentProps<"input">["onChange"] is the exact type React expects.

HandlerTypes.tsx
import type { ChangeEventHandler, ComponentProps, KeyboardEvent } from "react";

export const onNameChange: ChangeEventHandler<HTMLInputElement> = (e) => {
  console.log(e.target.value.toUpperCase());
};

type InputChange = ComponentProps<"input">["onChange"]; // ChangeEventHandler<HTMLInputElement, HTMLInputElement> | undefined

export function onKey(e: KeyboardEvent<HTMLInputElement>) {
  if (e.key === "Enter") e.currentTarget.blur();
}

export const wrong = (e: KeyboardEvent<HTMLInputElement>) => e.target.value; // ❌ Property 'value' does not exist on type 'EventTarget'.
export type { InputChange };
What’s happening
  1. ChangeEventHandler<HTMLInputElement> is the type of the whole function, so the parameter e is typed by context — the same trick that makes inline handlers work.
  2. ComponentProps<"input">["onChange"] is an indexed access type: "the type of the onChange prop of an <input>". It's a reliable way to find a handler's type without memorising names.
  3. In onKey, e.currentTarget is HTMLInputElement (the element the listener is on), so .blur() is available.
  4. In wrong, e.target is just EventTarget for a keyboard event, because a key event can bubble up from any descendant. It has no value property, so this is TS2339. Use e.currentTarget.value instead.
  5. ChangeEvent is the exception: React's types narrow target for <input>, <select> and <textarea> because those elements can't contain other form fields.

#Typing refs

Added

A ref is a box with a .current property that survives re-renders. The type question is always "what can be in the box?" For a DOM ref the answer is "the element, or null before React attaches it (and after it detaches it)". For an instance-variable ref (a timer id, a previous value) it's whatever you store.

React 19 changed the ref types in three ways: useRef now requires an argument; useRef<T>(null) returns RefObject<T | null> with a writable current (React 18's types had a confusing split between a read-only RefObject and a writable MutableRefObject, which is now deprecated); and function components receive ref as an ordinary prop, so forwardRef is no longer needed.

TextInput.tsx
import { useRef, type ComponentProps } from "react";

// React 19: ref is just a prop. ComponentProps<"input"> already includes it.
export function TextInput({ label, ...inputProps }: ComponentProps<"input"> & { label: string }) {
  return (
    <label>
      {label}
      <input {...inputProps} />
    </label>
  );
}

export function SearchBox() {
  const inputRef = useRef<HTMLInputElement>(null); // RefObject<HTMLInputElement | null>
  const renders = useRef(0); // RefObject<number>
  renders.current += 1;

  return (
    <div>
      <TextInput label="Search" ref={inputRef} />
      <button onClick={() => inputRef.current?.focus()}>Focus</button>
    </div>
  );
}
What’s happening
  1. useRef<HTMLInputElement>(null) returns RefObject<HTMLInputElement | null>. The | null is honest: on the first render, before React has created the <input>, current is null.
  2. ComponentProps<"input"> includes ref?: Ref<HTMLInputElement> in React 19, so TextInput accepts ref={inputRef} and passes it through with ...inputProps — no forwardRef.
  3. After React commits the DOM, it sets inputRef.current to the real <input> element. The button's handler uses inputRef.current?.focus(); the optional chaining is required because the type still includes null.
  4. useRef(0) is an instance-variable ref: inferred as RefObject<number>, and renders.current += 1 is allowed because current is writable in React 19's types.
  5. The test clicks "Focus" and checks that the input has focus, proving the ref travelled through TextInput to the DOM element.

Three ref mistakes that the React 19 types now catch:

RefMistakes.tsx
import { useRef } from "react";

export function Mistakes() {
  const timer = useRef<number>(); // ❌ Expected 1 arguments, but got 0.
  let saved: HTMLDivElement | null = null;
  const box = <div ref={(el) => (saved = el)} />; // ❌ Type '(el: HTMLDivElement | null) => HTMLDivElement | null' is not assignable to type 'Ref<HTMLDivElement> | undefined'.
  const ok = <div ref={(el) => { saved = el; }} />;
  return [timer, box, ok, saved];
}
What’s happening
  1. useRef<number>() with no argument compiled in React 18 (it meant undefined). In React 19 types every useRef overload takes one argument, so this is TS2554. Write useRef<number | undefined>(undefined) or useRef<number | null>(null).
  2. The arrow function (el) => (saved = el) returns the assignment's value, an HTMLDivElement | null. React 19 added ref cleanup functions: a callback ref may return a function that React calls on detach. To make that safe, the types only allow a callback ref to return nothing or a cleanup function — so an implicit return of an element is TS2322, which drills down to "Type 'null' is not assignable to type 'void | (() => VoidOrUndefinedOnly)'".
  3. The fix is a block body, (el) => { saved = el; }, which returns undefined. This is the codemod React's upgrade guide recommends (no-implicit-ref-callback-return).
  4. These errors are deliberate: each one would have been a subtle bug or ambiguity once ref cleanups exist.
AdvancedforwardRef (React 18 and earlier) and useImperativeHandle

Before React 19, a function component couldn't receive ref as a prop; you had to wrap it in forwardRef. Its type parameters are in the opposite order from the callback's parameters, which trips everyone up: forwardRef<RefType, PropsType>((props, ref) => …).

FancyInput.tsx
import { forwardRef, useImperativeHandle, useRef, type Ref } from "react";

// React 18 and earlier: forwardRef<the ref's type, the props' type>
export const OldInput = forwardRef<HTMLInputElement, { label: string }>(({ label }, ref) => (
  <label>
    {label}
    <input ref={ref} />
  </label>
));
OldInput.displayName = "OldInput";

// React 19: ref as a prop, here exposing a custom handle instead of the DOM node
export type FancyInputHandle = { focus: () => void; clear: () => void };

export function FancyInput({ ref }: { ref?: Ref<FancyInputHandle> }) {
  const inner = useRef<HTMLInputElement>(null);
  useImperativeHandle(ref, () => ({
    focus: () => inner.current?.focus(),
    clear: () => {
      if (inner.current) inner.current.value = "";
    },
  }));
  return <input aria-label="fancy" ref={inner} />;
}
What’s happening
  1. forwardRef<HTMLInputElement, { label: string }> — ref type first, props type second — while the render function is (props, ref). The result is a component whose ref prop accepts Ref<HTMLInputElement>.
  2. forwardRef returns an anonymous exotic component, so OldInput.displayName is set for DevTools. It still works in React 19, but React plans to deprecate it; new code shouldn't need it.
  3. FancyInput is the React 19 version: ref is destructured from props like any other prop and typed as Ref<FancyInputHandle>.
  4. useImperativeHandle(ref, () => ({ … })) makes the parent's ref point at the object you return instead of the DOM node. TypeScript checks that the object matches FancyInputHandle.
  5. A parent writes const r = useRef<FancyInputHandle>(null) and calls r.current?.clear(). The test does exactly that and checks the input is emptied.

#Typing context

Added

Context is a value that any descendant can read without props being passed down. Typing it has one awkward spot: createContext needs a default value, used when a component reads the context with no provider above it — and for most real contexts (the signed-in user, a store) there is no sensible default.

The pattern almost every typed codebase uses: create the context with null, and wrap useContext in a custom hook that throws if the value is null. The hook's return type is then the non-null value, so consumers never have to check for null, and a missing provider fails loudly with a clear message instead of undefined errors deep in the tree.

AuthContext.tsx
import { createContext, use, useState, type ReactNode } from "react";

type User = { id: number; name: string };
type AuthContextValue = {
  user: User | null;
  signIn: (name: string) => void;
  signOut: () => void;
};

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

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const value: AuthContextValue = {
    user,
    signIn: (name) => setUser({ id: Date.now(), name }),
    signOut: () => setUser(null),
  };
  return <AuthContext value={value}>{children}</AuthContext>; // React 19: <Context> is the provider
}

export function useAuth(): AuthContextValue {
  const ctx = use(AuthContext); // or useContext(AuthContext)
  if (ctx === null) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx; // narrowed to AuthContextValue
}

export function Greeting() {
  const { user, signIn, signOut } = useAuth();
  return user ? (
    <button onClick={signOut}>Sign out {user.name}</button>
  ) : (
    <button onClick={() => signIn("Rohit")}>Sign in</button>
  );
}
What’s happening
  1. createContext<AuthContextValue | null>(null) says the context holds an AuthContextValue, or null when no provider is present. The type argument is required; without it the context's type would be just null.
  2. AuthProvider builds the value from state. Annotating value: AuthContextValue checks it against the contract, and gives the signIn parameter name its string type.
  3. <AuthContext value={value}> is the React 19 way to provide a context; React 18 and earlier needed <AuthContext.Provider value={value}> (which still works).
  4. useAuth reads the context with use (React 19; useContext is the same here). If it's null, it throws; otherwise TypeScript narrows ctx to AuthContextValue, so the declared return type is satisfied without ! or as.
  5. Greeting destructures user, signIn and signOut with full types and no null checks on the context. The tests click "Sign in", see "Sign out Rohit", and also check that rendering Greeting without a provider throws "useAuth must be used inside <AuthProvider>".

#Generic components

A generic component is a component whose props mention a type parameter, exactly like a generic function. A List component doesn't care whether it lists users or products, but it does care that renderItem receives the same type as the items you passed. With a type parameter T, TypeScript infers T from the props at each call site: pass users, and renderItem's parameter is a User; pass strings, and it's a string.

Mental model: without generics you have two bad options — items: any[] (no checking at all) or one component per type (duplication). Generics give you one component with the checking of a type-specific one.

From my notes: "Generics in React Component" (heading only).

List.tsx
import type { Key, ReactNode } from "react";

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => ReactNode;
  getKey: (item: T) => Key;
  emptyText?: string;
};

export function List<T>({ items, renderItem, getKey, emptyText = "Nothing here" }: ListProps<T>) {
  if (items.length === 0) return <p>{emptyText}</p>;
  return (
    <ul>
      {items.map((item) => (
        <li key={getKey(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
What’s happening
  1. ListProps<T> is a generic props type: items is an array of T, and both callbacks receive a T. The three mentions of T are what tie them together.
  2. function List<T>(…: ListProps<T>) declares the type parameter on the component. Each <List … /> element is a call, so TypeScript infers T separately for every use.
  3. getKey returns a Key (string | number | bigint), the type React accepts for key. Asking the caller for a key function avoids the index-as-key bug for reorderable lists.
  4. Inside List, T is unknown — the component can't read item.name, because it doesn't know items have names. It can only pass items to the callbacks. That's what keeps it reusable.
  5. The test renders it with users and with an empty array (checking "Nothing here").
ListUsage.tsx
import { List } from "./List";

type User = { id: number; name: string };
const users: User[] = [{ id: 1, name: "Ada" }, { id: 2, name: "Linus" }];

export const names = <List items={users} getKey={(u) => u.id} renderItem={(u) => <b>{u.name}</b>} />;
export const tags = <List items={["react", "ts"]} getKey={(t) => t} renderItem={(t) => t.toUpperCase()} />;
export const typo = <List items={users} getKey={(u) => u.id} renderItem={(u) => u.email} />; // ❌ Property 'email' does not exist on type 'User'.
What’s happening
  1. In names, TypeScript sees items={users} and infers T = User. Then u in both callbacks is a User, so u.id and u.name are known properties.
  2. In tags, items is a string[], so T = string and t.toUpperCase() type-checks. Same component, different T.
  3. In typo, T is still User, and User has no email, so TS2339 points at u.email. With items: any[] this would have rendered nothing at runtime, silently.
  4. You never wrote <List<User> … />. You can pass the type argument explicitly in TSX, but inference from items is the normal case.
AdvancedConstraining T: a typed table with keyof

Constraints let the component use some knowledge about T. Here columns must be real keys of the row type, so a typo in a column name is a compile error:

Table.tsx
type Column<T> = { key: keyof T & string; header: string };

export function Table<T extends { id: string | number }>({ rows, columns }: { rows: T[]; columns: Column<T>[] }) {
  return (
    <table>
      <thead>
        <tr>{columns.map((c) => <th key={c.key}>{c.header}</th>)}</tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <tr key={row.id}>{columns.map((c) => <td key={c.key}>{String(row[c.key])}</td>)}</tr>
        ))}
      </tbody>
    </table>
  );
}

const people = [{ id: 1, name: "Ada", born: 1815 }];
export const ok = <Table rows={people} columns={[{ key: "name", header: "Name" }, { key: "born", header: "Born" }]} />;
export const bad = <Table rows={people} columns={[{ key: "age", header: "Age" }]} />; // ❌ Type '"age"' is not assignable to type '"born" | "id" | "name"'.
What’s happening
  1. T extends { id: string | number } is a constraint: any row type is allowed as long as it has an id. That's what lets the component use row.id as the key.
  2. keyof T & string is the union of T's keys that are strings. For people, T is { id: number; name: string; born: number }, so key must be "id" | "name" | "born" (the compiler prints the union sorted: "born" | "id" | "name").
  3. row[c.key] is an indexed access with a key known to exist, so it compiles without casts; String(…) turns the value into text.
  4. In bad, "age" isn't a key of the row type, so TS2322 lists the allowed keys. Rename a field in the data and every stale column definition lights up.

#Default props in TypeScript

Defaults for optional props used to be a separate static property, defaultProps. Today defaults are plain JavaScript default parameters in the destructuring, and TypeScript understands them perfectly: the prop is optional for the caller (size?: …), but inside the component the variable has the non-optional type because the default fills the gap.

This is one of the places where "old and new" really differ at runtime, not just in style: React 19 removed defaultProps for function components. React silently ignores them now — no warning — so old code that relied on them renders undefined after an upgrade. Class components still support static defaultProps.

From my notes: "Typing Default Props" (heading only).

Avatar.tsx
type AvatarProps = {
  name: string;
  size?: "sm" | "md" | "lg";
  rounded?: boolean;
};

export function Avatar({ name, size = "md", rounded = true }: AvatarProps) {
  // size: "sm" | "md" | "lg" (not undefined), rounded: boolean
  const initials = name
    .split(" ")
    .map((part) => part[0])
    .join("");
  return (
    <span className={`avatar avatar-${size}${rounded ? " avatar-round" : ""}`} title={name}>
      {initials}
    </span>
  );
}

export const a = <Avatar name="Ada Lovelace" />; // size "md", rounded true
export const b = <Avatar name="Linus" size="lg" rounded={false} />;
What’s happening
  1. In AvatarProps, size and rounded are optional, so callers can leave them out — a passes only name.
  2. The destructuring size = "md" applies the default whenever the prop is undefined. TypeScript knows that, so inside the component size has type "sm" | "md" | "lg" with no undefined.
  3. For a, the rendered class is avatar avatar-md avatar-round and the text is AL; for b, it's avatar avatar-lg with text L. The test checks both.
  4. Defaults apply only to undefined, not null: size={null} wouldn't compile here anyway, because null isn't in the prop's type.

The old way, side by side:

OldDefaults.tsx
import { Component } from "react";

// React 18 and earlier: defaultProps on a function component. React 19 IGNORES this.
function OldGreeting({ name }: { name?: string }) {
  return <p>Hello {String(name)}</p>;
}
OldGreeting.defaultProps = { name: "Guest" };

// Class components: static defaultProps still works in React 19.
class ClassGreeting extends Component<{ name: string }> {
  static defaultProps = { name: "Guest" };
  render() {
    return <p>Hi {this.props.name}</p>;
  }
}

export const both = (
  <>
    <OldGreeting />
    <ClassGreeting />
  </>
);
What’s happening
  1. OldGreeting.defaultProps = { name: "Guest" } was the pre-hooks way to default a function component's props. Under React 18 this rendered "Hello Guest".
  2. Under React 19.3 the test renders "Hello undefined": React no longer reads defaultProps on function components, and it doesn't log a warning about it. That's why the upgrade guide tells you to move to default parameters.
  3. ClassGreeting declares name: string as required, yet <ClassGreeting /> compiles. TypeScript's JSX checking uses JSX.LibraryManagedAttributes, which makes any prop listed in a class's static defaultProps optional at the call site.
  4. At runtime React still merges class defaultProps, so it renders "Hi Guest". Class components (and their defaultProps) are covered in Class components.

#Typing a higher-order component

A higher-order component (HOC) is a function that takes a component and returns a new component with some behaviour added (see Higher-order components for the pattern itself). Typing one is the "head twister" from my notes, because you're writing a generic function over component types: the HOC must accept any component, add or remove some props, and return a component whose props type is computed from the input's.

Two kinds of HOC need different types. A HOC that adds a prop the caller must pass (like isLoading) returns a component with more props than the wrapped one. A HOC that injects a prop (like the current user) returns a component with fewer props — the caller mustn't have to pass what the HOC provides.

From my notes, the typed HOC example (lightly reformatted):

withLoadingNotes.tsx
import type { ComponentType, FC } from "react";

// HOC Definition (from my notes)
function withLoading<T>(Component: ComponentType<T>) {
  return (props: T & { isLoading: boolean }) => {
    if (props.isLoading) {
      return <div>Loading...</div>;
    }
    return <Component {...props} />;
  };
}

interface UserProps {
  name: string;
}
const User: FC<UserProps> = ({ name }) => <div>{name}</div>;

const UserWithLoading = withLoading(User);
export const el = <UserWithLoading name="John Doe" isLoading={false} />;
What’s happening
  1. withLoading<T> is generic over the wrapped component's props T. ComponentType<T> accepts both function and class components with props T.
  2. The returned component's props are T & { isLoading: boolean } — everything the wrapped component needs, plus isLoading.
  3. withLoading(User) infers T = UserProps from the argument, so UserWithLoading requires name and isLoading. Leaving out either is a compile error, as it should be.
  4. When isLoading is false, it renders <Component {...props} />. This compiles (I checked it with TypeScript 7), and it works — the test renders "John Doe".
  5. But {...props} passes isLoading down too. User ignores it, but a component that spreads its props onto a DOM element would make React log "React does not recognize the isLoading prop on a DOM element". The returned component also has no name in DevTools (it shows as Anonymous).

Correcting my notes: the idea is right, but a production version should strip the prop it consumes, constrain T, and set a displayName. Stripping the prop is where the head-twister error appears:

withLoading.tsx
import type { ComponentType } from "react";

export function withLoading<P extends object>(Component: ComponentType<P>) {
  function WithLoading({ isLoading, ...rest }: P & { isLoading: boolean }) {
    if (isLoading) return <div role="status">Loading...</div>;
    return <Component {...(rest as P)} />; // without "as P": TS2769, see below
  }
  WithLoading.displayName = `withLoading(${Component.displayName ?? Component.name ?? "Component"})`;
  return WithLoading;
}
What’s happening
  1. P extends object constrains the props to an object type. It's what you'd expect props to be, and it makes the rest-spread below legal on a generic type.
  2. { isLoading, ...rest } pulls isLoading out, so it is not forwarded. TypeScript types rest as Omit<P & { isLoading: boolean }, "isLoading">.
  3. Without the cast, <Component {...rest} /> fails with TS2769, ending in: "'Omit<P & { isLoading: boolean; }, "isLoading">' is assignable to the constraint of type 'P', but 'P' could be instantiated with a different subtype of constraint 'object'." The compiler can't prove that removing isLoading from P & {isLoading} gives back exactly P — if P itself had an isLoading prop, it wouldn't.
  4. rest as P is the standard, deliberate assertion here: we know callers' P doesn't use isLoading for anything else. This one cast is the price of generic HOCs, and it's contained inside the HOC.
  5. Naming the inner function WithLoading and setting displayName to withLoading(User) makes React DevTools and error component stacks readable. The test checks the displayName and that the wrapped component never receives isLoading.

An injecting HOC computes the outer props by removing what it provides:

withUser.tsx
import type { ComponentType } from "react";

export type User = { id: number; name: string };
export type WithUserProps = { user: User };

const currentUser: User = { id: 7, name: "Rohit" }; // stands in for a context or store lookup

export function withUser<P extends WithUserProps>(Component: ComponentType<P>) {
  function WithUser(props: Omit<P, keyof WithUserProps>) {
    return <Component {...(props as P)} user={currentUser} />;
  }
  WithUser.displayName = `withUser(${Component.displayName ?? Component.name})`;
  return WithUser;
}

function Badge({ user, size }: WithUserProps & { size: number }) {
  return <span style={{ fontSize: size }}>{user.name}</span>;
}

export const UserBadge = withUser(Badge);
export const ok = <UserBadge size={14} />;
export const missing = <UserBadge />; // ❌ Property 'size' is missing in type '{}' but required in type 'Omit<WithUserProps & { size: number; }, "user">'.
export const notAllowed = <UserBadge size={14} user={currentUser} />; // ❌ Type '{ size: number; user: User; }' is not assignable to type 'IntrinsicAttributes & Omit<WithUserProps & { size: number; }, "user">'.
What’s happening
  1. P extends WithUserProps means "only components that accept a user prop can be wrapped" — wrapping a component that doesn't take user is a compile error at the withUser(…) call.
  2. The returned component's props are Omit<P, "user">: for Badge, that's { size: number }. The caller supplies size; the HOC supplies user.
  3. Inside, {...(props as P)} needs the same kind of assertion as before (an Omit<P, …> is not provably a P), then user={currentUser} fills in the missing prop.
  4. ok compiles and the test sees "Rohit" rendered at 14px. missing fails with TS2741 because size is still required, and notAllowed fails with TS2322 ("Property 'user' does not exist on type …") because user was removed from the outer props — the HOC owns it.
  5. In real code currentUser would come from a hook (useAuth() from Typing context) called inside WithUser; in new code you'd usually skip the HOC and call that hook directly in Badge.

#Typing custom hooks

Added

A custom hook is a function, so it's typed like one: parameters in, a return value out. Two things are React-specific. First, hooks that return a pair (like useState) need as const or an explicit tuple type, or TypeScript widens the return to an array of a union. Second, hooks that fetch or store data are usually generic, so the caller says what shape the data has — and you should be honest that this is a promise, not a runtime check.

useToggle.tsx
import { useCallback, useState } from "react";

export function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const; // readonly [boolean, () => void]
}

function useToggleWide(initial = false) {
  const [on, setOn] = useState(initial);
  return [on, () => setOn((v) => !v)]; // (boolean | (() => void))[]
}

export function Demo() {
  const [open, toggleOpen] = useToggle();
  const [wideOpen, wideToggle] = useToggleWide();
  wideToggle(); // ❌ This expression is not callable.
  return <button onClick={toggleOpen}>{open ? "Hide" : "Show"} {String(wideOpen)}</button>;
}
What’s happening
  1. useToggle returns [on, toggle] as const. as const makes the array a readonly tuple: position 0 is boolean, position 1 is () => void.
  2. So const [open, toggleOpen] = useToggle() gives open: boolean and toggleOpen: () => void, and onClick={toggleOpen} type-checks.
  3. useToggleWide returns the same array without as const. TypeScript infers an array type, (boolean | (() => void))[], so every element is "boolean or function".
  4. wideToggle() therefore fails with TS2349 — the follow-up line is "Not all constituents of type 'boolean | (() => void)' are callable." The caller would need a type guard to use its own toggle.
  5. The test uses renderHook(() => useToggle()), calls result.current[1]() inside act, and sees result.current[0] go false → true.

A generic data hook lets the caller name the data type, and returns a discriminated union so the caller must check the status:

useFetchJson.ts
import { useEffect, useState } from "react";

export type FetchState<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

export function useFetchJson<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ status: "loading" });

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading" });
    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>; // a promise to the compiler, not a runtime check
      })
      .then((data) => setState({ status: "success", data }))
      .catch((err: unknown) => {
        if (controller.signal.aborted) return;
        setState({ status: "error", error: err instanceof Error ? err : new Error(String(err)) });
      });
    return () => controller.abort();
  }, [url]);

  return state;
}
What’s happening
  1. useFetchJson<T> is generic, and the caller supplies T explicitly: useFetchJson<User[]>("/api/users"). Nothing in the arguments mentions T, so it can't be inferred.
  2. The state is FetchState<T>, the same discriminated-union idea as Typing state. The return type is declared, so callers get FetchState<User[]> and must check status before reading data.
  3. res.json() returns Promise<any>. The as Promise<T> is where the hook trusts the server: TypeScript will believe the data is T, but nothing checks it at runtime. For untrusted APIs, validate with a schema (Zod's schema.parse(json) returns a properly typed value — see Schema validation with Zod).
  4. In catch, err is unknown (TypeScript's default for useUnknownInCatchVariables under strict), so it's normalised into an Error. Aborted requests are ignored, because the effect cleanup aborts the previous request when url changes or the component unmounts.
  5. The test stubs fetch to return [{ id: 1, name: "Ada" }], renders the hook with renderHook, and waits for status to become "success" with that data; a second test returns a 500 and expects error.message to be "HTTP 500".
What's the type of value?
React + TypeScript
function useCounter() {
  const [count, setCount] = useState(0);
  return [count, () => setCount((c) => c + 1)];
}
const [value, inc] = useCounter();
Show answer

value is number | (() => void), not number.

What’s happening
  1. The hook returns an array literal with no as const and no declared return type, so TypeScript infers an array type from its elements.
  2. The elements are a number and a () => void, so the array type is (number | (() => void))[].
  3. Destructuring an array gives every variable the element type, so both value and inc are number | (() => void).
  4. Fix it with return [count, inc] as const (a readonly tuple [number, () => void]) or an explicit return type : [number, () => void].

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.