TypeScript · Lesson 11 of 12
TypeScript with React
Use TypeScript with React: typing props and children, useState, useRef and useReducer, event handlers, context, custom hooks and generic components.
- Intermediate
- 18 min read
- 4 objectives
Before this lessonLesson 10: tsconfig, Strict Mode and Tooling
What you will learn
- Type component props, children and defaults
- Type hooks: useState, useRef, useReducer and context
- Type events and forms
- Write generic, reusable components
Your Progress
0 of 12 lessons 0%
- Lessons0 / 12
- Completed0
- Est. time left~ 3 hours
Create a free account to keep your progress on every device.
Tip: pressing Next marks this lesson complete automatically.
React and TypeScript are the default pairing for front-end work in 2026: Vite, Next.js and React Router all start projects in TypeScript. Types make components self-documenting (hover a component and see exactly what it accepts) and catch the most common React bugs: missing props, wrong event types and state that might be null.
This lesson assumes you know basic React. To follow along, create a project with npm create vite@latest my-app -- --template react-ts. Component files use the .tsx extension.
Typing props
Props are just a function parameter, so you type them with an interface or type alias. Optional props get ?, and destructuring defaults fill them in. You do not need React.FC; a plain function with typed props is the recommended style.
interface BadgeProps {
label: string;
tone?: "neutral" | "success" | "danger";
count?: number;
}
export function Badge({ label, tone = "neutral", count }: BadgeProps) {
return (
<span className={`badge badge-${tone}`}>
{label}
{count !== undefined && <strong> {count}</strong>}
</span>
);
}
// <Badge label="Orders" tone="success" count={3} /> OK
// <Badge tone="info" /> Error: 'label' is missing, and "info" is not a valid toneChildren and wrapping native elements
Use React.ReactNode for anything renderable (text, elements, arrays, null). When your component wraps a native element, extend its props with ComponentProps<"button"> so callers can pass onClick, disabled, aria-* and everything else without you listing them.
import type { ComponentProps, ReactNode } from "react";
interface CardProps {
title: string;
children: ReactNode;
}
export function Card({ title, children }: CardProps) {
return <section><h2>{title}</h2>{children}</section>;
}
type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" };
export function Button({ variant = "primary", className, ...rest }: ButtonProps) {
return <button className={`btn btn-${variant} ${className ?? ""}`} {...rest} />;
}In React 19, ref is a normal prop for function components, so ComponentProps<"button"> already includes it and forwardRef is no longer needed for new code.
useState and useRef
useState infers its type from the initial value, so useState(0) is a number. You only need a type argument when the initial value does not show the full type, typically an empty array or null.
import { useEffect, useRef, useState } from "react";
interface User { id: number; name: string; }
export function Profile() {
const [count, setCount] = useState(0); // number (inferred)
const [tags, setTags] = useState<string[]>([]); // without <string[]> this is never[]
const [user, setUser] = useState<User | null>(null); // may be null until loaded
const inputRef = useRef<HTMLInputElement>(null); // DOM ref
useEffect(() => {
inputRef.current?.focus(); // current may be null
}, []);
if (!user) return <p>Loading...</p>;
return <input ref={inputRef} defaultValue={user.name} />; // user: User here
}Event handlers and forms
Inline handlers are typed for you by context. When you extract a handler into its own function, use React's event types, which are generic over the element: React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>, React.MouseEvent<HTMLButtonElement>. A quick trick: hover the onChange prop in your editor to see the exact type.
import { useState, type ChangeEvent, type FormEvent } from "react";
export function SignupForm({ onSubmit }: { onSubmit: (email: string) => void }) {
const [email, setEmail] = useState("");
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setEmail(e.target.value); // e.target is an HTMLInputElement
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
onSubmit(email);
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={handleChange} />
<button type="submit">Join stackcone</button>
</form>
);
}useReducer with a discriminated union
For state with several related transitions, useReducer plus a discriminated union of actions is where TypeScript shines: each case knows exactly which payload its action carries, and a typo in an action type is a compile error. The reducer is plain logic, so here it is in JavaScript with its output:
function cartReducer(state, action) {
switch (action.type) {
case "add":
return { items: [...state.items, action.item] };
case "remove":
return { items: state.items.filter((i) => i.sku !== action.sku) };
case "clear":
return { items: [] };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
let state = { items: [] };
state = cartReducer(state, { type: "add", item: { sku: "pen", price: 3 } });
state = cartReducer(state, { type: "add", item: { sku: "book", price: 12 } });
state = cartReducer(state, { type: "remove", sku: "pen" });
console.log(JSON.stringify(state));{"items":[{"sku":"book","price":12}]}And the TypeScript types that make it safe:
interface Item { sku: string; price: number; }
interface CartState { items: Item[]; }
type CartAction =
| { type: "add"; item: Item }
| { type: "remove"; sku: string }
| { type: "clear" };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "add": return { items: [...state.items, action.item] }; // action.item: Item
case "remove": return { items: state.items.filter((i) => i.sku !== action.sku) };
case "clear": return { items: [] };
}
}
// In a component:
// const [cart, dispatch] = useReducer(cartReducer, { items: [] });
// dispatch({ type: "remove", sku: "pen" }); OK
// dispatch({ type: "remove" }); Error: 'sku' is missingContext and custom hooks
A context usually has no sensible default value, so type it as T | null and wrap access in a custom hook that throws if the provider is missing. Every consumer then gets a non-null value without repeating checks.
import { createContext, useContext, useState, type ReactNode } from "react";
interface AuthContextValue {
user: { name: string } | null;
login: (name: string) => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthContextValue["user"]>(null);
return (
<AuthContext value={{ user, login: (name) => setUser({ name }) }}>
{children}
</AuthContext>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}React 19 lets you render <AuthContext> directly as the provider; in older versions write <AuthContext.Provider>.
Generic components
Components can be generic, just like functions. A list or table that works for any row type, while keeping renderItem fully typed, is the classic example.
import type { Key, ReactNode } from "react";
interface ListProps<T> {
items: T[];
getKey: (item: T) => Key;
renderItem: (item: T) => ReactNode;
}
export function List<T>({ items, getKey, renderItem }: ListProps<T>) {
return <ul>{items.map((item) => <li key={getKey(item)}>{renderItem(item)}</li>)}</ul>;
}
// T is inferred as { id: string; total: number }, so o.total is typed
// <List items={orders} getKey={(o) => o.id} renderItem={(o) => `$${o.total}`} />Recap
- Type props with an interface; skip
React.FC; useReactNodefor children. - Extend
ComponentProps<"button">to wrap native elements; in React 19refis a normal prop. - Give
useStatea type argument when starting from[]ornull; type DOM refs asuseRef<HTMLInputElement>(null). - Use React's generic event types for extracted handlers, and discriminated unions for reducer actions.
- Type context as
T | nullbehind a custom hook that throws; make reusable components generic.
// Write your solution here
Finished reading? Mark this lesson complete to track your progress.
