React course menu
React with TypeScript: Typing Props, State and Events
Use TypeScript in React: creating a react-ts project, typing component props with types and interfaces, optional and children props, typing useState, event handlers, refs, useReducer actions with discriminated unions, context, and custom hooks.
On this page
TypeScript catches mistakes in React code before you run it: a missing prop, a misspelt property, passing a string where a number is expected. Most professional React projects use it. This lesson assumes you know TypeScript basics.
Start a TypeScript project with the react-ts template:
npm create vite@latest my-app -- --template react-tsComponent files then use the .tsx extension.
Typing props#
Describe the props with a type (or interface) and annotate the parameter:
type ProductCardProps = {
name: string;
price: number;
inStock?: boolean; // optional
tags?: string[];
};
function ProductCard({ name, price, inStock = true, tags = [] }: ProductCardProps) {
return (
<div style={{ border: "1px solid #ddd", padding: 8, marginBottom: 6 }}>
<strong>{name}</strong> — ₹{price.toFixed(2)} {inStock ? "" : "(out of stock)"}
{tags.length > 0 && <div><small>{tags.join(" · ")}</small></div>}
</div>
);
}
export default function App() {
return (
<div>
<ProductCard name="Steel bottle" price={349} tags={["eco", "1 litre"]} />
<ProductCard name="Yoga mat" price={899} inStock={false} />
</div>
);
}With these types, the editor autocompletes props, and these mistakes are compile errors:
<ProductCard name="Bottle" /> // error: Property 'price' is missing
<ProductCard name="Bottle" price="349" /> // error: Type 'string' is not assignable to type 'number'
<ProductCard name="Bottle" price={349} colour="red" /> // error: Property 'colour' does not exist(The previews strip the types and run the JavaScript; the TypeScript compiler or your editor reports the errors.)
children and other React types#
children: React.ReactNodeaccepts anything React can render: text, elements, arrays,null.React.ComponentProps<"button">gives all the props of a<button>, useful for wrapping native elements.
import type { ComponentProps, ReactNode } from "react";
type CardProps = { title: string; children: ReactNode };
function Card({ title, children }: CardProps) {
return <section><h3>{title}</h3>{children}</section>;
}
type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "secondary" };
function Button({ variant = "primary", ...rest }: ButtonProps) {
return <button className={`btn btn-${variant}`} {...rest} />;
}Button now accepts every normal button prop (onClick, disabled, type...) plus variant, which is limited to two values.
Older code often writes components as const Card: React.FC<CardProps> = .... Annotating the props parameter directly, as above, is simpler and now the common style.
Typing state#
TypeScript infers the type from the initial value. Give a type argument when the initial value does not show the full type, such as null or an empty array:
import { useState } from "react";
type User = { id: number; name: string };
export default function App() {
const [count, setCount] = useState(0); // number, inferred
const [user, setUser] = useState<User | null>(null); // starts empty
const [tags, setTags] = useState<string[]>([]); // empty array needs a type
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>{" "}
<button onClick={() => setUser({ id: 1, name: "Zara" })}>Log in</button>{" "}
<button onClick={() => setTags([...tags, `tag${tags.length + 1}`])}>Add tag</button>
<p>{user ? `Hello, ${user.name}` : "Not logged in"} · tags: {tags.join(", ") || "none"}</p>
</div>
);
}Because user is User | null, TypeScript makes you check for null before using user.name, which prevents a classic crash.
Typing events#
Inline handlers get their event types automatically. For separate handler functions, use React's event types:
import type { ChangeEvent, FormEvent, MouseEvent } from "react";
function handleChange(e: ChangeEvent<HTMLInputElement>) {
console.log(e.target.value); // string
}
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
}
function handleClick(e: MouseEvent<HTMLButtonElement>) {
console.log(e.clientX, e.clientY);
}Tip: hover over onChange in your editor to see the exact type it expects.
Typing refs#
const inputRef = useRef<HTMLInputElement>(null);
// later, in an event handler:
inputRef.current?.focus(); // current may be null until React attaches the elementReducers with discriminated unions#
A union of action types with a shared type field makes reducers type-safe: inside each case, TypeScript knows exactly which fields the action has.
import { useReducer } from "react";
type State = { items: string[]; filter: "all" | "short" };
type Action =
| { type: "added"; text: string }
| { type: "removed"; index: number }
| { type: "filterChanged"; filter: State["filter"] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "added":
return { ...state, items: [...state.items, action.text] };
case "removed":
return { ...state, items: state.items.filter((_, i) => i !== action.index) };
case "filterChanged":
return { ...state, filter: action.filter };
}
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { items: ["Tea", "Chocolate biscuits"], filter: "all" });
const visible = state.filter === "short" ? state.items.filter((t) => t.length <= 5) : state.items;
return (
<div>
<button onClick={() => dispatch({ type: "added", text: "Jam" })}>Add Jam</button>{" "}
<button onClick={() => dispatch({ type: "filterChanged", filter: state.filter === "all" ? "short" : "all" })}>
Show {state.filter === "all" ? "short names only" : "all"}
</button>
<ul>
{visible.map((item) => (
<li key={item}>
{item} <button onClick={() => dispatch({ type: "removed", index: state.items.indexOf(item) })}>✕</button>
</li>
))}
</ul>
</div>
);
}Dispatching { type: "added" } without text, or a misspelt type such as "add", is a compile error. Because every case returns, TypeScript also checks that the reducer handles all action types.
Typing context#
import { createContext, useContext } from "react";
type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function useTheme() {
const value = useContext(ThemeContext);
if (!value) throw new Error("useTheme must be used inside <ThemeProvider>");
return value; // type: ThemeContextValue, not null
}Typing custom hooks#
Return tuples as const, so callers get exact types instead of a mixed array:
import { useState } from "react";
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((v) => !v);
return [on, toggle] as const; // readonly [boolean, () => void]
}Without as const, the return type would be (boolean | (() => void))[], and const [on, toggle] = useToggle() would give both variables that union type.
Common mistakes#
Practice#
Practice exercise
Type a StudentRow component that takes name: string, marks: number, and an optional onSelect callback that receives the name. Render two rows and show the selected name.
Show hint
type StudentRowProps = { name: string; marks: number; onSelect?: (name: string) => void }; and call onSelect?.(name) in the click handler.
Show solution
import { useState } from "react";
type StudentRowProps = {
name: string;
marks: number;
onSelect?: (name: string) => void;
};
function StudentRow({ name, marks, onSelect }: StudentRowProps) {
return (
<p>
{name}: {marks} <button onClick={() => onSelect?.(name)}>Select</button>
</p>
);
}
export default function App() {
const [selected, setSelected] = useState<string | null>(null);
return (
<div>
<StudentRow name="Ishaan" marks={91} onSelect={setSelected} />
<StudentRow name="Mira" marks={84} onSelect={setSelected} />
<p>Selected: {selected ?? "nobody"}</p>
</div>
);
}Key takeaway#
Next: Routing with React Router.
Finished this lesson?