TypeScript with React
React + TypeScript is the most popular front-end combination today. Typed props mean your editor knows every component’s API, and mistakes show up before you open the browser.
On this page · 6 min · Advanced
On this page
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 6 min
- Difficulty
- Advanced
- Updated
- Oct 28, 2026
Learning objectives#
- Type component props and children
- Type useState, useRef and useReducer
- Type event handlers and forms
- Write a typed custom hook
- Type a context provider
Explanation#
Props are an interface or type: function Button({ label, onClick }: ButtonProps). Optional props use ?; defaults go in destructuring.
children: React.ReactNode accepts anything renderable. Extend native props with React.ComponentProps<"button"> to accept every normal button attribute.
useState infers from the initial value; give it a type when it starts empty: useState<User | null>(null), useState<string[]>([]).
useRef<HTMLInputElement>(null) for DOM refs. Events: React.ChangeEvent<HTMLInputElement>, React.FormEvent<HTMLFormElement>, React.MouseEvent<HTMLButtonElement>.
useReducer works best with a discriminated union of actions. Context: createContext<Value | null>(null) plus a hook that throws if used outside the provider.
Syntax#
interface Props { title: string; count?: number }
function Badge({ title, count = 0 }: Props) {
return <span>{title}: {count}</span>;
}
Code example: A typed to-do component#
import { useReducer, useRef, type ReactNode, type FormEvent, type ComponentProps } from 'react';
interface Todo { id: number; text: string; done: boolean }
type Action =
| { type: 'add'; text: string }
| { type: 'toggle'; id: number }
| { type: 'remove'; id: number };
function reducer(todos: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add': return [...todos, { id: Date.now(), text: action.text, done: false }];
case 'toggle': return todos.map(t => (t.id === action.id ? { ...t, done: !t.done } : t));
case 'remove': return todos.filter(t => t.id !== action.id);
}
}
type ButtonProps = ComponentProps<'button'> & { variant?: 'primary' | 'danger' };
function Button({ variant = 'primary', ...rest }: ButtonProps) {
return <button className={`btn btn-${variant}`} {...rest} />;
}
function Card({ title, children }: { title: string; children: ReactNode }) {
return <section><h2>{title}</h2>{children}</section>;
}
export function TodoApp() {
const [todos, dispatch] = useReducer(reducer, []);
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const text = inputRef.current?.value.trim();
if (!text) return;
dispatch({ type: 'add', text });
inputRef.current!.value = '';
}
return (
<Card title={`To-do (${todos.length})`}>
<form onSubmit={handleSubmit}>
<input ref={inputRef} aria-label="New task" />
<Button type="submit">Add</Button>
</form>
<ul>
{todos.map(t => (
<li key={t.id}>
<label>
<input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'toggle', id: t.id })} />
{t.text}
</label>
<Button variant="danger" onClick={() => dispatch({ type: 'remove', id: t.id })}>Delete</Button>
</li>
))}
</ul>
{/* <Button variant="warning" /> ✗ Type '"warning"' is not assignable to type '"primary" | "danger"' */}
{/* dispatch({ type: 'toggle' }) ✗ Property 'id' is missing */}
</Card>
);
}
A working to-do list: type a task and press Add, tick it off, or delete it. Invalid button variants and malformed actions are compile errors.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- Todo and Action describe the data; Action is a discriminated union.
- reducer’s switch narrows each action, so action.text exists only for "add".
- ButtonProps reuses every native button prop and adds variant.
- Card types children as ReactNode.
- useRef<HTMLInputElement> types the input ref; FormEvent types the submit handler.
- Invalid props and actions are caught by the compiler.
Real-world use case#
Every component library (MUI, Chakra, shadcn/ui) ships typed props, so your editor autocompletes them and flags invalid combinations.
Common mistakes#
An empty array infers never[], and you cannot add anything. Write useState<Todo[]>([]).
Use the specific React event types; hover over onChange in your editor to see the exact type.
Plain functions with typed props are simpler and are the common recommendation today.
Best practices#
- Type props with an interface or type next to the component
- Extend native element props with ComponentProps<"tag">
- Type empty and nullable state explicitly
- Use discriminated unions for reducer actions
- Make context hooks throw when used outside their provider
Remember this#
Props = an interface. children = ReactNode.
Empty state? Tell it the type: useState<T[]>([]).
Hover the handler to learn the event type.
Practice exercise#
Your task: Create a typed Avatar component with props src (string), alt (string), size ("sm" | "md" | "lg", default "md") and optional onClick.
Need a hint?
Show solution
interface AvatarProps {
src: string;
alt: string;
size?: 'sm' | 'md' | 'lg';
onClick?: () => void;
}
const sizes = { sm: 32, md: 48, lg: 64 };
export function Avatar({ src, alt, size = 'md', onClick }: AvatarProps) {
const px = sizes[size];
return <img src={src} alt={alt} width={px} height={px} onClick={onClick} style={{ borderRadius: '50%' }} />;
}
Mini quiz#
How do you type state that starts as null but will hold a User?
Want more? Practise all TypeScript MCQs · TypeScript interview questions
My notes#
Saved in this browser.