Skip to main content
TypeScript · lesson 18 of 21
TypeScript intermediate Lesson 18 of 21

React with TypeScript

Type React 19 props, children, hooks, events, context and generic components, verified with real tsc errors and server-rendered output from tsx.

By SkillByExample · Updated

Everything here lives in one small project: source files in src/, type-checked with npx tsc and rendered to HTML with renderToString from react-dom/server, run by npx tsx — so the outputs are real compiler errors and real React output, with no browser involved. Versions: TypeScript 6.0.3, React 19.3 and @types/react 19.3, Node 22.

{
  "compilerOptions": {
    "module": "nodenext",
    "jsx": "react-jsx",
    "noEmit": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src"]
}

"jsx": "react-jsx" uses the automatic runtime, so components don’t need import React. The folder’s package.json has "type": "module".

Typing Props

Type props as a plain object type on the function parameter. Optional props get defaults in the destructuring.

type ButtonProps = {
  label: string;
  variant?: "primary" | "secondary" | "danger";
  disabled?: boolean;
  onClick?: () => void;
};

export function Button({ label, variant = "primary", disabled = false, onClick }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} disabled={disabled} onClick={onClick}>
      {label}
    </button>
  );
}
import { renderToString } from "react-dom/server";
import { Button } from "./Button.js";

console.log(renderToString(<Button label="Save" />));
console.log(renderToString(<Button label="Delete" variant="danger" disabled />));
$ npx tsc
$ npx tsx src/render-button.tsx
<button class="btn btn-primary">Save</button>
<button class="btn btn-danger" disabled="">Delete</button>

The defaults show up in the markup: the first button got btn-primary. Misuse is caught at the call site:

import { Button } from "./Button.js";

export const a = <Button />;
export const b = <Button label="Go" variant="warning" />;
export const c = <Button label="Go" onclick={() => {}} />;
$ npx tsc
src/bad-button.tsx(3,19): error TS2741: Property 'label' is missing in type '{}' but required in type 'ButtonProps'.
src/bad-button.tsx(4,37): error TS2322: Type '"warning"' is not assignable to type '"primary" | "secondary" | "danger" | undefined'.
src/bad-button.tsx(5,37): error TS2322: Type '{ label: string; onclick: () => void; }' is not assignable to type 'IntrinsicAttributes & ButtonProps'.
  Property 'onclick' does not exist on type 'IntrinsicAttributes & ButtonProps'. Did you mean 'onClick'?

The last error is the kind of typo that silently does nothing in plain JavaScript: React’s prop is onClick, and the error even suggests it.

Children and Composition

children is just a prop. ReactNode accepts anything React can render: elements, strings, numbers, arrays, null.

import type { ReactNode } from "react";

type CardProps = { title: string; footer?: ReactNode; children: ReactNode };

export function Card({ title, footer, children }: CardProps) {
  return (
    <section>
      <h2>{title}</h2>
      {children}
      {footer && <footer>{footer}</footer>}
    </section>
  );
}
import { renderToString } from "react-dom/server";
import { Card } from "./Card.js";

console.log(renderToString(<Card title="Stats">{["3 users", " / ", 42]}</Card>));
console.log(renderToString(<Card title="Empty" footer={<em>updated now</em>}>{null}</Card>));
$ npx tsc
$ npx tsx src/render-card.tsx
<section><h2>Stats</h2>3 users<!-- --> / <!-- -->42</section>
<section><h2>Empty</h2><footer><em>updated now</em></footer></section>

An array of strings and numbers is valid ReactNode, and so is null. The <!-- --> comments are React’s separators between adjacent text nodes, so the browser can split them apart again when it hydrates. Because children is required in CardProps, <Card title="x" /> with nothing inside would be a compile error — use children?: ReactNode when it’s optional.

Typing Hooks

useState infers from its initial value. When the initial value doesn’t show every possible type — null for “not loaded yet”, an empty array — pass the type argument.

import { useState, useReducer, useRef } from "react";

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

type State = { status: "idle" } | { status: "loaded"; users: User[] } | { status: "error"; message: string };
type Action = { type: "loaded"; users: User[] } | { type: "failed"; message: string };

export function reducer(_state: State, action: Action): State {
  switch (action.type) {
    case "loaded": return { status: "loaded", users: action.users };
    case "failed": return { status: "error", message: action.message };
  }
}

export function Users() {
  const [count, setCount] = useState(0);
  const [selected, setSelected] = useState<User | null>(null);
  const [state, dispatch] = useReducer(reducer, { status: "idle" });
  const input = useRef<HTMLInputElement>(null);

  setCount("1");
  setSelected({ id: 1 });
  dispatch({ type: "loaded" });
  input.current.focus();
  return <p>{count} {selected?.name} {state.status}</p>;
}
$ npx tsc
src/hooks.tsx(21,12): error TS2345: Argument of type 'string' is not assignable to parameter of type 'SetStateAction<number>'.
src/hooks.tsx(22,15): error TS2345: Argument of type '{ id: number; }' is not assignable to parameter of type 'SetStateAction<User | null>'.
  Property 'name' is missing in type '{ id: number; }' but required in type 'User'.
src/hooks.tsx(23,12): error TS2345: Argument of type '{ type: "loaded"; }' is not assignable to parameter of type 'Action'.
  Property 'users' is missing in type '{ type: "loaded"; }' but required in type '{ type: "loaded"; users: User[]; }'.
src/hooks.tsx(24,3): error TS18047: 'input.current' is possibly 'null'.

Each hook carries its type into its setter: count is a number; selected needs its explicit User | null, otherwise it would be stuck as null; dispatch accepts only the Action union; and in React 19 a DOM ref starts as null, so input.current must be checked. The reducer is a plain function, so it can be exercised directly:

import { reducer } from "./hooks.js";

let state = reducer({ status: "idle" }, { type: "loaded", users: [{ id: 1, name: "Ada" }] });
console.log(state);
state = reducer(state, { type: "failed", message: "timeout" });
console.log(state);
$ npx tsc
$ npx tsx src/run-reducer.tsx
{ status: 'loaded', users: [ { id: 1, name: 'Ada' } ] }
{ status: 'error', message: 'timeout' }

With the four bad lines removed the project checks clean. Keeping state transitions in a typed reducer makes them testable without rendering anything.

Typing Events

Inline handlers get their event type from the prop. Handlers defined separately need React’s event types, parameterised by the element.

import { useState, type ChangeEvent, type FormEvent } from "react";

export function Search({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState("");

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value);
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSearch(query.trim());
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={handleChange} />
      <button onClick={(e) => console.log(e.currentTarget.form?.id)}>Go</button>
      <select onChange={handleChange} />
    </form>
  );
}
$ npx tsc
src/Search.tsx(16,15): error TS2322: Type '(e: ChangeEvent<HTMLInputElement>) => void' is not assignable to type 'ChangeEventHandler<HTMLSelectElement, HTMLSelectElement>'.
  Types of parameters 'e' and 'event' are incompatible.
    Type 'ChangeEvent<HTMLSelectElement, HTMLSelectElement>' is not assignable to type 'ChangeEvent<HTMLInputElement, Element>'.
      Type 'HTMLSelectElement' is missing the following properties from type 'HTMLInputElement': accept, align, alt, capture, and 38 more.

handleChange is typed for an <input>, so attaching it to a <select> is rejected — e.target would be a different element type. The inline onClick needed no annotation: e was inferred as a mouse event on an HTMLButtonElement, so e.currentTarget.form type-checks. Remove the <select> line and the project compiles cleanly: tsc prints nothing.

$ npx tsc

Typed Context

Create the context with null as its default, and wrap useContext in a hook that throws when there’s no provider. Consumers then get a non-nullable value.

import { createContext, useContext, useState, type ReactNode } from "react";

type AuthUser = { name: string; roles: string[] };
type AuthValue = { user: AuthUser | null; login: (user: AuthUser) => void; logout: () => void };

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

export function AuthProvider({ initialUser = null, children }: { initialUser?: AuthUser | null; children: ReactNode }) {
  const [user, setUser] = useState(initialUser);
  return <AuthContext value={{ user, login: setUser, logout: () => setUser(null) }}>{children}</AuthContext>;
}

export function useAuth(): AuthValue {
  const value = useContext(AuthContext);
  if (!value) throw new Error("useAuth must be used inside <AuthProvider>");
  return value;
}

export function Greeting() {
  const { user } = useAuth();
  return <p>{user ? `Hello, ${user.name} (${user.roles.join(", ")})` : "Please sign in"}</p>;
}
import { renderToString } from "react-dom/server";
import { AuthProvider, Greeting } from "./auth.js";

console.log(renderToString(<AuthProvider initialUser={{ name: "Ada", roles: ["admin"] }}><Greeting /></AuthProvider>));
console.log(renderToString(<AuthProvider><Greeting /></AuthProvider>));
try {
  renderToString(<Greeting />);
} catch (e) {
  console.log("error:", (e as Error).message);
}
$ npx tsc
$ npx tsx src/render-auth.tsx
<p>Hello, Ada (admin)</p>
<p>Please sign in</p>
error: useAuth must be used inside <AuthProvider>

React 19 lets you render the context itself as the provider (<AuthContext value=...>); <AuthContext.Provider> still works. useState(initialUser) inferred AuthUser | null from the prop’s type. The third render shows the hook’s guard: rendering Greeting outside a provider fails loudly instead of rendering with a null it wasn’t typed for.

Generic Components

A component can be generic like a function. The type parameter is inferred from the props, and it links them together.

import type { ReactNode } from "react";

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => string | number;
  render: (item: T) => ReactNode;
};

export function List<T>({ items, getKey, render }: ListProps<T>) {
  return <ul>{items.map((item) => <li key={getKey(item)}>{render(item)}</li>)}</ul>;
}
import { renderToString } from "react-dom/server";
import { List } from "./List.js";

const users = [{ id: 1, name: "Ada" }, { id: 2, name: "Grace" }];

console.log(renderToString(<List items={users} getKey={(u) => u.id} render={(u) => <b>{u.name}</b>} />));
console.log(renderToString(<List items={["x", "y"]} getKey={(s) => s} render={(s) => s.toUpperCase()} />));
export const bad = <List items={users} getKey={(u) => u.email} render={(u) => u.name} />;
$ npx tsc
src/render-list.tsx(8,57): error TS2339: Property 'email' does not exist on type '{ id: number; name: string; }'.

T was inferred as { id: number; name: string } from items, so the callbacks know the item type without annotations — and u.email is caught. Remove the bad line:

$ npx tsx src/render-list.tsx
<ul><li><b>Ada</b></li><li><b>Grace</b></li></ul>
<ul><li>X</li><li>Y</li></ul>

Extending HTML Element Props

A wrapper around a native element should accept every attribute the element does. ComponentProps<"input"> gives exactly that set.

import type { ComponentProps } from "react";

type InputProps = ComponentProps<"input"> & { label: string; error?: string };

export function Input({ label, error, id, ...rest }: InputProps) {
  const inputId = id ?? label.toLowerCase().replace(/\s+/g, "-");
  return (
    <p>
      <label htmlFor={inputId}>{label}</label>
      <input id={inputId} aria-invalid={error ? true : undefined} {...rest} />
      {error && <span role="alert">{error}</span>}
    </p>
  );
}
import { renderToString } from "react-dom/server";
import { Input } from "./Input.js";

console.log(renderToString(<Input label="Work email" type="email" required placeholder="you@company.com" />));
console.log(renderToString(<Input label="Age" type="number" min={0} error="Must be positive" />));
$ npx tsc
$ npx tsx src/render-input.tsx
<p><label for="work-email">Work email</label><input id="work-email" type="email" required="" placeholder="you@company.com"/></p>
<p><label for="age">Age</label><input id="age" aria-invalid="true" type="number" min="0"/><span role="alert">Must be positive</span></p>

type, required, placeholder and min all flowed through ...rest with full type checking — min="zero" is fine for HTML but min={{}} would be rejected. In React 19, ref is an ordinary prop included in ComponentProps<"input">, so the wrapper forwards refs without forwardRef.

Practice

1. Make Card’s children optional and render <Card title="Nothing here" />.

Solution
import type { ReactNode } from "react";
import { renderToString } from "react-dom/server";

function Card({ title, children }: { title: string; children?: ReactNode }) {
  return <section><h2>{title}</h2>{children ?? <p>No content yet.</p>}</section>;
}

console.log(renderToString(<Card title="Nothing here" />));
$ npx tsc
$ npx tsx src/practice-1.tsx
<section><h2>Nothing here</h2><p>No content yet.</p></section>

2. Give List an optional empty prop (a ReactNode) that renders when items is empty.

Solution
import type { ReactNode } from "react";
import { renderToString } from "react-dom/server";

type ListProps<T> = { items: T[]; render: (item: T) => ReactNode; empty?: ReactNode };

function List<T>({ items, render, empty = <p>Nothing to show</p> }: ListProps<T>) {
  if (items.length === 0) return <>{empty}</>;
  return <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>;
}

const none: number[] = [];
console.log(renderToString(<List items={none} render={(n) => n * 2} />));
console.log(renderToString(<List items={[1, 2]} render={(n) => n * 2} empty="—" />));
$ npx tsc
$ npx tsx src/practice-2.tsx
<p>Nothing to show</p>
<ul><li>2</li><li>4</li></ul>

none needs its number[] annotation: a bare [] literal would infer T as never, and then n * 2 couldn’t be checked meaningfully.

3. Add a "reset" action to the hooks.tsx reducer’s Action union without handling it. What does tsc report, and where?

Solution
type State = { status: "idle" } | { status: "loaded"; count: number };
type Action = { type: "loaded"; count: number } | { type: "reset" };

export function reducer(_state: State, action: Action): State {
  switch (action.type) {
    case "loaded": return { status: "loaded", count: action.count };
  }
}
$ npx tsc
src/practice-3.tsx(4,57): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

The switch no longer covers every action, so the function can end without returning a State. The error is on the return type — add case "reset": return { status: "idle" }; to fix it.

Frequently Asked Questions

Should I use React.FC or just type props directly?
Type props directly on the function parameter. It is simpler, works naturally with generic components, and makes the return type inferable. Since React 18, React.FC no longer adds an implicit children prop, so the main reason to use it is gone.
How do I type a ref in TypeScript?
Use useRef<HTMLInputElement>(null) for DOM refs. In React 19 this returns a RefObject<HTMLInputElement | null>, so check ref.current before using it. For a mutable value that is not a DOM element, pass an initial value of the right type, such as useRef<number>(0).
How do I share typed state across components?
Create a context with createContext<YourType | null>(null), then write a custom hook that calls useContext and throws if the value is null. Components using the hook get a non-nullable type.
How can I check React component types without a browser?
Run tsc --noEmit with jsx set to react-jsx; it type-checks every component, prop and event handler. To see what a component renders, call renderToString from react-dom/server in a small script run with tsx, which prints the HTML without a DOM.
What type should an event handler parameter have?
Use React's synthetic event types with the element as the type argument, such as React.ChangeEvent<HTMLInputElement> or React.MouseEvent<HTMLButtonElement>. When the handler is written inline in JSX, TypeScript infers the event type for you from the prop.