TL;DR
Type React components, props, hooks, events, and context with TypeScript. Catch UI bugs at compile time and build self-documenting code.
Key concepts
- React TypeScript
- TypeScript React props
- typed hooks
- React TypeScript tutorial
React and TypeScript
TypeScript and React work together to catch UI bugs at compile time. Typed props, hooks, and events make components self-documenting and refactor-safe.
How this lesson runs: the runnable fences here are plain TypeScript — the typing patterns React uses (props interfaces, typed reducers, event-payload shapes, generic components), exercised as ordinary functions that print to the console. Real React needs JSX and the framework's runtime, neither of which exists in this sandbox: there is no
reactpackage to import and no JSX transform for a.tsfile. So the actual-JSX examples below are markedno-run— they show what the finished component looks like, but the runner won't execute them. Where a React-specific type is named (React.FC,React.ChangeEvent,React.FormEvent,ReactNode), it is the typing shipped by@types/react19 (React 19). Version matters more here than the names suggest:React.FC<P>is declared asFunctionComponent<P = {}>in those typings, so it does not add achildrenprop for you — a component that takes children must say so in its own props type. Check which@types/reactyour project has before copying a signature from anywhere, this lesson included. The logic those components wrap is runnable, and that's what the build task and the interactive fences focus on: everything you can verify by running, you run; JSX is shown for shape.
What You'll Learn
By the end of this lesson you will write the typed reducer that sits underneath a useReducer hook — an action union the compiler can prove you handled, a state it is not allowed to mutate, and a fold that threads a whole list of actions through it. You will also be able to say, of any React typing you meet, which part of it is doing work at the call site and which part is doing work inside the component, because those are two different jobs a props type performs at once.
This lesson is not tied to a capstone milestone; it is here because React is where most TypeScript is actually written, and almost none of what makes it work is React-specific. You arrive able to describe an object's shape with an interface (Interfaces and Types) and to write a function that stays type-safe over any element type (Generics) — a component is a function that takes one object, so both apply directly. What is new is the direction the types flow: a props interface is a contract the caller must satisfy, and the payoff shows up at the call site rather than inside the function you wrote.
Typing Props
Define component props with interfaces for type-safe component APIs.
// Props interfaces
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}
interface CardProps {
title: string;
children: string; // simplified — in React this would be ReactNode
footer?: string;
}
// Simulate component rendering
function Button(props: ButtonProps): string {
const { label, variant = "primary", disabled = false } = props;
const state = disabled ? " (disabled)" : "";
return `<button class="${variant}"${state}>${label}</button>`;
}
function Card(props: CardProps): string {
const { title, children, footer } = props;
let html = `<div class="card"><h2>${title}</h2><div>${children}</div>`;
if (footer) html += `<footer>${footer}</footer>`;
html += `</div>`;
return html;
}
console.log(Button({ label: "Submit", onClick: () => {}, variant: "primary" }));
console.log(Button({ label: "Delete", onClick: () => {}, variant: "danger", disabled: true }));
console.log(Card({ title: "Welcome", children: "Hello, TypeScript!" }));
console.log(Card({ title: "Info", children: "With footer", footer: "Last updated today" }));
In a real React project the same ButtonProps interface types an actual JSX component. This fence is no-run — the sandbox has no react package and no JSX transform — but it shows how the props type carries over unchanged; only the return value (string above) becomes real JSX:
import type { ReactNode } from "react";
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
disabled?: boolean;
}
interface CardProps {
title: string;
children: ReactNode; // in real React, children is ReactNode, not string
footer?: string;
}
function Button({ label, onClick, variant = "primary", disabled = false }: ButtonProps) {
return (
<button className={variant} onClick={onClick} disabled={disabled}>
{label}
</button>
);
}
function Card({ title, children, footer }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div>{children}</div>
{footer && <footer>{footer}</footer>}
</div>
);
}
The props interface is identical; the compiler still rejects a missing label or a variant outside the union. Everything you practice below on the logic side transfers directly to typed JSX like this.
The model to carry through the rest of the lesson: a props type is one declaration doing two jobs in opposite directions. At the call site it is a requirement — every required field must be supplied and every value must fall inside its declared type, so <Button variant="tertiary" /> is rejected before it renders. Inside the component body the same declaration is a guarantee — props.label is a string with no check needed, and props.variant is one of exactly three strings, which is what lets you switch on it without a fallback.
Use the model to predict a case that is not on this page. Make label optional (label?: string) and both directions move at once, in opposite ways: the call site gets easier (omitting it is now legal) and the body gets harder (label is now string | undefined, so interpolating it can produce the text "undefined" unless you supply a default). That trade is the whole reason optional props usually arrive with a default in the destructuring, as variant and disabled do above — the default is what buys the guarantee back. The same rule explains why widening a prop to string feels convenient and costs you the exhaustive switch: what you relax for the caller you pay for in the body.
Typing Hooks
Type useState, useReducer, and custom hooks for safe state management.
// Simulate React hooks for demonstration
// useState — type is inferred or explicit
function useState<T>(initial: T): [T, (value: T | ((prev: T) => T)) => T] {
let state = initial;
const setState = (value: T | ((prev: T) => T)): T => {
state = typeof value === "function" ? (value as (prev: T) => T)(state) : value;
return state;
};
return [state, setState];
}
// Basic usage — type inferred
const [count, setCount] = useState(0);
console.log(`Count: ${count}`);
console.log(`After increment: ${setCount(prev => prev + 1)}`);
// Explicit type for complex state
interface FormState {
name: string;
email: string;
errors: Record<string, string>;
}
const [form, setForm] = useState<FormState>({
name: "",
email: "",
errors: {}
});
console.log(`Form: ${JSON.stringify(form)}`);
// useReducer pattern
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset"; payload: number };
interface CounterState {
value: number;
}
function reducer(state: CounterState, action: Action): CounterState {
switch (action.type) {
case "increment": return { value: state.value + 1 };
case "decrement": return { value: state.value - 1 };
case "reset": return { value: action.payload };
}
}
let state: CounterState = { value: 10 };
state = reducer(state, { type: "increment" });
console.log(`After increment: ${state.value}`);
state = reducer(state, { type: "decrement" });
console.log(`After decrement: ${state.value}`);
state = reducer(state, { type: "reset", payload: 0 });
console.log(`After reset: ${state.value}`);
Predict
This reducer's Action union requires a value field on the set action, but the dispatch below omits it: { type: 'set' }. This is the kind of mistake a typed reducer is meant to prevent. The code is checked with tsc --strict AND run by the tsx runner. Predict BOTH: what does tsc report, and what does the runner print?
type Action =
| { type: "increment" }
| { type: "set"; value: number };
function reducer(count: number, action: Action): number {
switch (action.type) {
case "increment": return count + 1;
case "set": return action.value;
}
}
console.log(reducer(0, { type: "set" }));Event Handling
Type event handlers to catch errors in event handling code.
// Simulated event payload types. These are named FieldChange / FormSubmit / KeyPress
// rather than ChangeEvent / SubmitEvent / KeyboardEvent because those last two are
// built-in DOM lib globals — reusing the names would shadow them and fail tsc.
// In real React you would import React.ChangeEvent<HTMLInputElement>, etc.
interface FieldChange {
target: { value: string; name: string };
}
interface FormSubmit {
preventDefault: () => void;
}
interface KeyPress {
key: string;
ctrlKey: boolean;
}
// Typed event handlers
function handleChange(event: FieldChange): void {
console.log(`Field "${event.target.name}" changed to: ${event.target.value}`);
}
function handleSubmit(event: FormSubmit): void {
event.preventDefault();
console.log("Form submitted");
}
function handleKeyDown(event: KeyPress): void {
if (event.ctrlKey && event.key === "s") {
console.log("Save shortcut detected");
} else {
console.log(`Key pressed: ${event.key}`);
}
}
// Test the handlers
handleChange({ target: { value: "Alice", name: "username" } });
handleChange({ target: { value: "alice@example.com", name: "email" } });
handleSubmit({ preventDefault: () => console.log(" (default prevented)") });
handleKeyDown({ key: "s", ctrlKey: true });
handleKeyDown({ key: "Enter", ctrlKey: false });
Those handler types describe the event's shape. Nothing about them describes what you do with the fields once you have destructured them, which is where the next bug lives.
Debug
This is the reducer half of a controlled form: each field change carries the name of the field that changed and its new value, and applyChange folds one change into the form state. Two changes go in — one for name, one for email — and the form comes out with the email in the wrong field and the email field still empty. The types are right and tsc is silent. Commit a hypothesis about which of the two destructured fields applyChange is actually using and which one it ignores, then fix it so each change lands on its own field.
import assert from "node:assert";
interface FieldChange {
target: { value: string; name: string };
}
interface FormState {
name: string;
email: string;
}
// Apply one field change to the form state.
function applyChange(state: FormState, event: FieldChange): FormState {
const { name, value } = event.target;
return { ...state, name: value };
}
let form: FormState = { name: "", email: "" };
form = applyChange(form, { target: { name: "name", value: "Ada" } });
form = applyChange(form, { target: { name: "email", value: "ada@x.co" } });
assert.deepStrictEqual(
form,
{ name: "Ada", email: "ada@x.co" },
"each change should update its own field, got " + JSON.stringify(form),
);
console.log("form:", JSON.stringify(form));Expected output:
form: {"name":"Ada","email":"ada@x.co"}Generic Components
Create reusable components that work with any data type.
// Why is the type parameter on ListProps<T> rather than on renderItem alone?
// Because it has to tie two props together: items and renderItem must agree on
// the element type. Put T only on renderItem and each prop would be free to pick
// its own, so List({ items: users, renderItem: (p: Product) => ... }) would type-
// check and blow up at run time.
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => string;
emptyMessage?: string;
}
function List<T>(props: ListProps<T>): string {
const { items, renderItem, emptyMessage = "No items" } = props;
if (items.length === 0) return `<p>${emptyMessage}</p>`;
const rendered = items.map((item, i) => `<li>${renderItem(item, i)}</li>`);
return `<ul>${rendered.join("")}</ul>`;
}
// Use with different types
interface User { name: string; role: string }
interface Product { name: string; price: number }
const users: User[] = [
{ name: "Alice", role: "Admin" },
{ name: "Bob", role: "User" }
];
const products: Product[] = [
{ name: "Laptop", price: 999 },
{ name: "Mouse", price: 29 }
];
console.log("Users:");
console.log(List({
items: users,
renderItem: (user) => `${user.name} (${user.role})`
}));
console.log("\nProducts:");
console.log(List({
items: products,
renderItem: (product) => `${product.name}: $${product.price}`
}));
console.log("\nEmpty:");
console.log(List<string>({
items: [],
renderItem: (s) => s,
emptyMessage: "Nothing to show"
}));
// Your turn — this one is not worked for you. Write Table<T>, which takes items
// and a columns array where each column is { header: string; cell: (item: T) =>
// string }, and returns an HTML table string. Everything you need is above: the
// T that ties two props together, the map over items, and the join. The decision
// to make yourself is where T has to appear so that a column's cell function is
// typed as receiving an ITEM and not something looser.
That last exercise is where the pattern stops being about lists. List<T> and Table<T> differ only in the shape of the render argument, and both are the same idea: one type parameter, threaded through every prop that has to agree, inferred once from the data you pass. Nothing about it is React — a generic component is a generic function, and the props object is just its parameter.
Recall
Without scrolling up: List<T> is a generic component, and calling List({ items: users, renderItem: (user) => ... }) needed no explicit <User> — user was already typed. You met this inference from arguments in the Generics lesson. What lets TypeScript know T is User here, and why did the empty-array call List<string>({ items: [], ... }) need the explicit <string>?
Context Typing
Type React context to ensure providers and consumers agree on the shape.
// Simulated context system
interface ThemeContext {
mode: "light" | "dark";
primary: string;
toggle: () => string;
}
interface AuthContext {
user: { name: string; role: string } | null;
login: (name: string) => void;
logout: () => void;
}
// Context "providers"
function createThemeContext(): ThemeContext {
let mode: "light" | "dark" = "light";
return {
mode,
primary: "#007bff",
toggle() {
mode = mode === "light" ? "dark" : "light";
this.mode = mode;
return mode;
}
};
}
function createAuthContext(): AuthContext {
let user: { name: string; role: string } | null = null;
return {
user,
login(name: string) {
user = { name, role: "user" };
this.user = user;
console.log(`Logged in as ${name}`);
},
logout() {
user = null;
this.user = null;
console.log("Logged out");
}
};
}
// "Components" consuming context
function Header(theme: ThemeContext, auth: AuthContext): string {
const greeting = auth.user ? `Welcome, ${auth.user.name}` : "Please log in";
return `[${theme.mode}] ${greeting}`;
}
const theme = createThemeContext();
const auth = createAuthContext();
console.log(Header(theme, auth));
auth.login("Alice");
console.log(Header(theme, auth));
console.log(`Theme toggled to: ${theme.toggle()}`);
console.log(Header(theme, auth));
auth.logout();
console.log(Header(theme, auth));
Try It Yourself
You've typed props, hooks, events, and generic components. Now build the piece that powers a useReducer hook: the reducer itself, framework-free so it runs here (see How this lesson runs at the top). The Action union and State are from the Typing Hooks section's reducer pattern. Each function is stubbed with only its signature. Run it as-is to see the first failure, implement that, then work down until it prints All checks passed.
Build
Finish the build. Three functions are stubbed with only their signatures; the checks below them fail until each returns the right value. Spec: reducer(state, action) returns a NEW state for each action — increment adds 1 to count, decrement subtracts 1, set replaces count with action.value; every action also appends the resulting count to a copy of history, and the reducer must never mutate the state passed in (end the switch with a never default for exhaustiveness). run(initial, actions) folds the whole action list through reducer, starting from initial. currentValue(state) returns the latest count. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// A typed reducer — the same shape React's useReducer runs, but framework-free so
// it runs here. Domain locked — do not edit.
interface State {
count: number;
history: number[];
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "set"; value: number };
// TODO 1
function reducer(state: State, action: Action): State {
return state;
}
// TODO 2
function run(initial: State, actions: Action[]): State {
return initial;
}
// TODO 3
function currentValue(state: State): number {
return 0;
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const start: State = { count: 0, history: [] };
assert.deepStrictEqual(reducer(start, { type: "increment" }), { count: 1, history: [1] }, "TODO 1: increment adds 1 and records it");
assert.deepStrictEqual(reducer({ count: 5, history: [5] }, { type: "decrement" }), { count: 4, history: [5, 4] }, "TODO 1: decrement subtracts 1 and records it");
assert.deepStrictEqual(reducer(start, { type: "set", value: 9 }), { count: 9, history: [9] }, "TODO 1: set uses the action's value");
assert.deepStrictEqual(start, { count: 0, history: [] }, "TODO 1: reducer must not mutate the state passed in");
const final = run(start, [{ type: "increment" }, { type: "increment" }, { type: "set", value: 10 }, { type: "decrement" }]);
assert.deepStrictEqual(final, { count: 9, history: [1, 2, 10, 9] }, "TODO 2: run folds every action through the reducer");
assert.strictEqual(currentValue(final), 9, "TODO 3: currentValue returns the latest count");
console.log("All checks passed.");
console.log("final:", JSON.stringify(final));
console.log("value:", currentValue(final));Expected output:
All checks passed.
final: {"count":9,"history":[1,2,10,9]}
value: 9Once it passes, try two variations and predict each before running:
- Forget to record the
setaction in history. Change thesetcase toreturn { count: action.value, history: state.history };(reuse the old history, don't append). Predict which check fails first before running. The count is right, buthistoryno longer grows forset, soreducer(start, { type: "set", value: 9 })returnshistory: []instead of[9], and TODO 1's third check fails first —AssertionError: TODO 1: set uses the action's value, withhistory: []where[9]was expected. Every action records its result; skipping one leaves a gap. - Fold right-to-left in
run. Changeruntoreturn actions.reduceRight(reducer, initial);. Predict which check fails first before running.reduceRightapplies the actions in reverse order — decrement, set 10, increment, increment — so the final count is12, not9, and TODO 2's check fails first —count: 12where9was expected. A reducer fold is order-sensitive; dispatch order is the semantics, not an implementation detail.
Arrange the code
These five lines are what a component does between receiving its props and returning its markup, with the JSX taken away: build a props object, derive the disabled state from it, assemble the attribute string, wrap it in the element, and log the result. The lines are shuffled. Put them back in an order that runs - then answer what the puzzle is really testing: which line could not have moved, and what does the runner do if it does?
const state = props.disabled ? " disabled" : "";const props = { label: "Save", variant: "primary" as const, disabled: false };console.log(html);const attrs = `class="${props.variant}"${state}`;const html = `<button ${attrs}>${props.label}</button>`;
Transfer
The reducer you built takes a state and a discriminated action, narrows on the action's tag, and returns a new state — and the build ran it with Array.prototype.reduce, no React anywhere. The same shape turns up as a Redux store, as a state machine in a game loop, and as a fold over a list of events in a server-side log replay. What is the invariant that survives all four settings, and what is actually different about React's version?
Key Takeaways
- Define props with interfaces — optional props use
?, defaults via destructuring - Type
useStateexplicitly when the type can't be inferred from the initial value - Use discriminated unions for reducer actions (each action has a unique
type) - Generic components (
List<T>) work with any data type while staying type-safe - Type event handlers to match React's event types (
React.ChangeEvent,React.FormEvent, etc.) - Context types ensure providers and consumers agree on the data shape
Pro Tip: When props get complex, split them into smaller interfaces and compose with intersection types:
type Props = LayoutProps & DataProps & EventProps. This keeps each concern focused and makes individual prop groups reusable across components.
Next Steps
You've typed React components with props, hooks, events, and reducers. For the final lesson, you'll apply TypeScript to a full-stack framework: typed page props, generic API wrappers, server-action result unions, and a typed data layer in Next.js.
Ready to continue? Head to Next.js and TypeScript!
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.