TypeScript with React
React and TypeScript are a very common pairing. Types catch wrong or missing props, make hooks safer, and turn your editor into a guide to every component's API. This lesson covers the patterns you'll use daily: typing props, state, events, refs, context and generic components.
Starting a project
npm create vite@latest my-app -- --template react-ts cd my-app npm install npm run dev
Component files use the .tsx extension, which allows JSX. Frameworks like Next.js and React Router also generate TypeScript projects by default.
Typing props
Describe a component's props with an interface or type alias, and annotate the props parameter:
type BadgeProps = { label: string; tone?: "info" | "success" | "danger"; count?: number; }; export function Badge({ label, tone = "info", count }: BadgeProps) { return ( <span className={`badge badge-${tone}`}> {label} {count !== undefined && <strong> {count}</strong>} </span> ); } // <Badge label="Inbox" count={3} /> ✓ // <Badge label="Error" tone="red" /> ✗ "red" is not a valid tone // <Badge tone="success" /> ✗ label is missing
Optional props get a ?, and default values go in the destructuring pattern. Using a literal union for tone means callers get autocomplete and typos are rejected.
Children and other special props
import type { ReactNode } from "react"; type CardProps = { title: string; children: ReactNode; footer?: ReactNode; }; export function Card({ title, children, footer }: CardProps) { return ( <section className="card"> <h2>{title}</h2> {children} {footer && <footer>{footer}</footer>} </section> ); }
ReactNode covers anything React can render: elements, strings, numbers, arrays, null. To accept all the attributes of a native element plus your own, extend React's built-in props type:
import type { ComponentProps } from "react"; type ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost"; }; export function Button({ variant = "primary", ...rest }: ButtonProps) { return <button className={`btn btn-${variant}`} {...rest} />; } // <Button onClick={save} disabled type="submit">Save</Button>
useState
When the initial value says everything, inference is enough. When it doesn't — such as starting with null or an empty array — pass a type argument:
import { useState } from "react"; type User = { id: number; name: string }; export function Profile() { const [count, setCount] = useState(0); const [user, setUser] = useState<User | null>(null); const [tags, setTags] = useState<string[]>([]); return ( <div> <button onClick={() => setCount(c => c + 1)}>{count}</button> <p>{user ? user.name : "Not signed in"}</p> <button onClick={() => setTags([...tags, "new"])}>{tags.length} tags</button> <button onClick={() => setUser({ id: 1, name: "Ana" })}>Sign in</button> </div> ); }
Because user is User | null, TypeScript insists on the user ? … : … check before reading user.name.
Events
Inline handlers are typed automatically. When you define a handler separately, use React's event types:
import { useState, type ChangeEvent, type FormEvent } from "react"; export function Search({ onSearch }: { onSearch: (q: string) => void }) { const [query, setQuery] = useState(""); function handleChange(e: ChangeEvent<HTMLInputElement>) { setQuery(e.target.value); } function handleSubmit(e: FormEvent<HTMLFormElement>) { e.preventDefault(); onSearch(query.trim()); } return ( <form onSubmit={handleSubmit}> <input value={query} onChange={handleChange} /> </form> ); }
Tip: hover over onChange in your editor to see exactly which event type it expects, then copy it.
useRef
import { useEffect, useRef } from "react"; export function AutoFocusInput() { const inputRef = useRef<HTMLInputElement>(null); const renders = useRef(0); useEffect(() => { inputRef.current?.focus(); renders.current += 1; }); return <input ref={inputRef} />; }
A DOM ref starts as null and is filled in by React after rendering, so inputRef.current is HTMLInputElement | null and needs ?.. A ref used as a mutable box (renders) infers its type from the initial value.
Context
import { createContext, useContext, useState, type ReactNode } from "react"; type Theme = "light" | "dark"; type ThemeContextValue = { theme: Theme; toggle: () => void }; const ThemeContext = createContext<ThemeContextValue | null>(null); export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState<Theme>("light"); const toggle = () => setTheme(t => (t === "light" ? "dark" : "light")); return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>; } export function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) throw new Error("useTheme must be used inside <ThemeProvider>"); return ctx; }
Creating the context with null and checking for it in a custom hook means every consumer gets a non-null, fully typed value — and a clear error if the provider is missing.
Generic components
Components can be generic, just like functions. A list that works with any item type and keeps the type in its callbacks:
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>; } // <List items={users} getKey={u => u.id} render={u => u.name} /> // u is inferred as the user type in both callbacks
Typing custom hooks
import { useEffect, useState } from "react"; type FetchState<T> = | { status: "loading" } | { status: "error"; error: string } | { status: "success"; data: T }; export function useFetch<T>(url: string): FetchState<T> { const [state, setState] = useState<FetchState<T>>({ status: "loading" }); useEffect(() => { let cancelled = false; fetch(url) .then(r => (r.ok ? (r.json() as Promise<T>) : Promise.reject(new Error(r.statusText)))) .then(data => !cancelled && setState({ status: "success", data })) .catch((e: unknown) => !cancelled && setState({ status: "error", error: String(e) })); return () => { cancelled = true; }; }, [url]); return state; }
Returning a discriminated union (lesson 23) means components must check status before touching data — no more "data is undefined" crashes during loading.
React.FC<Props> in older code. It still works, but typing the props parameter directly, as shown throughout this lesson, is now the more common style and handles generics and default values more naturally.