TypeScript with React: The Complete Guide
March 19, 2025 · 9 min read
React, TypeScript, JavaScript, Type Safety, Interview Prep
Introduction
TypeScript adds static type checking to React applications, catching errors at compile time and providing excellent IDE support. This article covers typing props, state, events, and advanced patterns like generics and discriminated unions.
Typing Component Props
// Basic props interface
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean; // Optional
variant?: 'primary' | 'secondary' | 'danger'; // Union type
}
function Button({
label,
onClick,
disabled = false,
variant = 'primary'
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
}
Children Prop
interface CardProps {
title: string;
children: React.ReactNode; // Accepts anything renderable
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// More specific children types
interface ListProps {
children: React.ReactElement[]; // Only elements
}
interface RenderPropProps {
children: (data: { loading: boolean }) => React.ReactNode;
}
Typing Events
function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onSearch(e.target.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<form onSubmit={handleSubmit}>
<input type="text" onChange={handleChange} />
</form>
);
}
// Common event types
type InputChange = React.ChangeEvent<HTMLInputElement>;
type SelectChange = React.ChangeEvent<HTMLSelectElement>;
type FormSubmit = React.FormEvent<HTMLFormElement>;
type ButtonClick = React.MouseEvent<HTMLButtonElement>;
type KeyDown = React.KeyboardEvent<HTMLInputElement>;
Typing Hooks
useState
// Type inference works for simple types
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
// Explicit typing for complex state
interface User {
id: string;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
const [users, setUsers] = useState<User[]>([]);
useReducer
interface State {
count: number;
step: number;
}
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'setStep'; payload: number }
| { type: 'reset' };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'setStep':
return { ...state, step: action.payload };
case 'reset':
return { count: 0, step: 1 };
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
return (
<div>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'setStep', payload: 5 })}>
Step 5
</button>
</div>
);
}
useRef
// DOM refs - initialize with null
function TextInput() {
const inputRef = useRef<HTMLInputElement>(null);
const focus = () => {
inputRef.current?.focus(); // current might be null
};
return <input ref={inputRef} />;
}
// Mutable refs - no null
function Timer() {
const intervalRef = useRef<number | null>(null);
useEffect(() => {
intervalRef.current = window.setInterval(() => {}, 1000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
}
Generic Components
Generics preserve type information through component APIs:
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage - T is inferred from items
interface Product {
id: string;
name: string;
price: number;
}
<List
items={products} // T = Product
renderItem={(product) => <span>{product.name}</span>}
keyExtractor={(product) => product.id}
/>
Generic Select Component
interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue,
}: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selected = options.find(opt => getValue(opt) === e.target.value);
if (selected) onChange(selected);
}}
>
{options.map(option => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
Discriminated Unions
Model state machines with discriminated unions:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function useFetch<T>(url: string) {
const [state, setState] = useState<AsyncState<T>>({ status: 'idle' });
useEffect(() => {
setState({ status: 'loading' });
fetch(url)
.then(res => res.json())
.then(data => setState({ status: 'success', data }))
.catch(error => setState({ status: 'error', error }));
}, [url]);
return state;
}
// Type-safe rendering
function ProductList() {
const state = useFetch<Product[]>('/api/products');
switch (state.status) {
case 'idle':
return <div>Ready</div>;
case 'loading':
return <Spinner />;
case 'success':
// TypeScript knows state.data is Product[]
return <List items={state.data} />;
case 'error':
// TypeScript knows state.error is Error
return <Error message={state.error.message} />;
}
}
Why Discriminated Unions
This pattern makes invalid states unrepresentable. You can't access data on a loading state or error on a success state. TypeScript enforces this at compile time.
Type Safety Pays Off
Discriminated unions eliminated an entire class of bugs in Glucoplate - no more accessing data that might be undefined during loading. TypeScript forces you to handle every state explicitly. Worth the upfront investment.
Typing Context
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Type-safe hook
function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// Provider
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = useCallback(async (email: string, password: string) => {
const user = await authApi.login(email, password);
setUser(user);
}, []);
const value: AuthContextType = {
user,
isAuthenticated: !!user,
login,
logout: useCallback(async () => {
await authApi.logout();
setUser(null);
}, []),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
Utility Types
interface ProductProps {
id: string;
name: string;
price: number;
description: string;
category: string;
onEdit: () => void;
onDelete: () => void;
}
// Pick specific props
type ProductSummaryProps = Pick<ProductProps, 'name' | 'price'>;
// Omit certain props
type ProductDisplayProps = Omit<ProductProps, 'onEdit' | 'onDelete'>;
// Make all optional
type PartialProductProps = Partial<ProductProps>;
// Make all required
type RequiredProductProps = Required<PartialProductProps>;
// Extract component props
type ButtonProps = React.ComponentProps<typeof Button>;
type InputProps = React.ComponentProps<'input'>;
// Extend HTML props
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
function CustomInput({ label, error, ...props }: CustomInputProps) {
return (
<div>
<label>{label}</label>
<input {...props} aria-invalid={!!error} />
{error && <span>{error}</span>}
</div>
);
}
forwardRef with TypeScript
interface InputProps {
label: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} aria-invalid={!!error} />
{error && <span>{error}</span>}
</div>
)
);
// Usage
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <Input ref={inputRef} label="Email" />;
}
as const Assertion
// Without as const
const STATUSES = ['pending', 'active', 'completed'];
// Type: string[]
// With as const
const STATUSES = ['pending', 'active', 'completed'] as const;
// Type: readonly ['pending', 'active', 'completed']
// Extract union type
type Status = typeof STATUSES[number];
// Type: 'pending' | 'active' | 'completed'
// Object with as const
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
} as const;
// All properties are readonly literals
Interview Questions
Q1: type vs interface - when to use each?
Answer:
| Interface | Type |
|---|---|
| Object shapes | Unions, tuples, primitives |
| Extendable, mergeable | Computed types |
| Class contracts | Mapped types |
// Interface - extendable
interface User { name: string; }
interface Admin extends User { permissions: string[]; }
// Type - unions, tuples
type Status = 'active' | 'inactive';
type Coordinates = [number, number];
Both work for props. Consistency matters more than the choice.
Q2: How do you type event handlers?
Answer:
// Inline
<input onChange={(e: React.ChangeEvent<HTMLInputElement>) => {}} />
// Extracted
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {};
// Common types
React.ChangeEvent<HTMLInputElement> // input onChange
React.FormEvent<HTMLFormElement> // form onSubmit
React.MouseEvent<HTMLButtonElement> // button onClick
Q3: What are generic components?
Answer: Components with type parameters that preserve type information:
function List<T>({ items }: { items: T[] }) { ... }
<List items={users} /> // T = User, fully typed
Use for reusable components like lists, tables, selects.
Q4: How do you handle nullable values safely?
Answer:
// Optional chaining
const name = user?.profile?.name;
// Nullish coalescing
const displayName = user?.name ?? 'Anonymous';
// Type narrowing
if (!user) return <Login />;
// TypeScript knows user is not null here
// Type guard
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value;
}
Common Mistakes
1. Using any
// ❌ Defeats TypeScript's purpose
const handleData = (data: any) => {};
// ✅ Use unknown and narrow
const handleData = (data: unknown) => {
if (isUser(data)) {
console.log(data.name);
}
};
2. Not Handling Null
// ❌ Crashes if user is null
function Profile({ user }: { user: User | null }) {
return <div>{user.name}</div>; // Error!
}
// ✅ Handle null
function Profile({ user }: { user: User | null }) {
if (!user) return <Login />;
return <div>{user.name}</div>;
}
3. Wrong Event Types
// ❌ Wrong type
const handleClick = (e: React.ChangeEvent) => {};
<button onClick={handleClick} /> // Error!
// ✅ Correct type
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};
4. Over-Typing
// ❌ Unnecessary - TypeScript infers
const [count, setCount] = useState<number>(0);
// ✅ Let TypeScript infer simple types
const [count, setCount] = useState(0);
// Do type when inference doesn't work
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<Item[]>([]);
Summary
- Props interfaces define clear component contracts
- Event types use
React.ChangeEvent<T>,React.MouseEvent<T>, etc. - Generics create reusable, type-safe components
- Discriminated unions model state machines safely
- Utility types (
Pick,Omit,Partial) manipulate types - Let inference work - only annotate boundaries and complex types
- Avoid
any- useunknownwith type guards
Next up: React 18 & 19 Features - concurrent rendering, Suspense, Server Components, and the future of React.
Part 10 of the React Developer Reference series.