TypeScript with React
TypeScript with React
TypeScript and React are a powerful combination that brings type safety to your React applications. In this lesson, we'll explore how to properly type React components, props, state, hooks, events, context, and refs for a fully type-safe React development experience.
Setting Up React with TypeScript
The easiest way to start a React + TypeScript project is using Create React App with the TypeScript template.
<# Create new React app with TypeScript
npx create-react-app my-app --template typescript
# Or with Vite (faster alternative)
npm create vite@latest my-app -- --template react-ts
# Project structure
my-app/
├── src/
│ ├── App.tsx # TypeScript + JSX
│ ├── index.tsx
│ └── components/
├── tsconfig.json # TypeScript configuration
└── package.json
>
Typing Function Components
Function components in TypeScript can be typed using the React.FC type or by explicitly typing the props parameter.
<import React from 'react';
// Method 1: Using React.FC (Functional Component)
interface UserCardProps {
name: string;
email: string;
age?: number;
}
const UserCard: React.FC<UserCardProps> = ({ name, email, age }) => {
return (
<div>
<h2>{name}</h2>
<p>{email}</p>
{age && <p>Age: {age}</p>}
</div>
);
};
// Method 2: Explicit props typing (preferred)
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
disabled?: boolean;
}
const Button = ({ label, onClick, variant = 'primary', disabled }: ButtonProps) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{label}
</button>
);
};
// With children prop
interface ContainerProps {
title: string;
children: React.ReactNode;
}
const Container = ({ title, children }: ContainerProps) => {
return (
<div>
<h1>{title}</h1>
{children}
</div>
);
};
>
children prop that React.FC adds.
Typing Props with Children
When your component accepts children, use React.ReactNode for maximum flexibility.
<// Single child of specific type
interface IconProps {
children: React.ReactElement;
size?: number;
}
// Multiple children
interface CardProps {
children: React.ReactNode; // Can be anything React can render
header?: React.ReactNode;
footer?: React.ReactNode;
}
// Render prop pattern
interface DataListProps<T> {
data: T[];
renderItem: (item: T, index: number) => React.ReactNode;
}
function DataList<T>({ data, renderItem }: DataListProps<T>) {
return (
<ul>
{data.map((item, index) => (
<li key={index}>{renderItem(item, index)}</li>
))}
</ul>
);
}
// Usage
<DataList
data={users}
renderItem={(user, index) => (
<div>{user.name}</div>
)}
/>
>
Typing State with useState
TypeScript can often infer the state type from the initial value, but explicit typing is useful for complex types or when the initial value is null/undefined.
<import { useState } from 'react';
// Type inference (recommended when possible)
const [count, setCount] = useState(0); // Type: number
const [name, setName] = useState('John'); // Type: string
// Explicit typing
const [age, setAge] = useState<number>(25);
// Union types
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
// Complex objects
interface User {
id: number;
name: string;
email: string;
}
const [user, setUser] = useState<User | null>(null);
// Arrays
const [users, setUsers] = useState<User[]>([]);
// Updating state with type safety
setUser({
id: 1,
name: 'John',
email: 'john@example.com'
});
// Functional updates are type-safe
setCount(prevCount => prevCount + 1);
setUsers(prevUsers => [...prevUsers, newUser]);
// Error: Type string is not assignable to type User
// setUser('invalid');
>
Typing Event Handlers
React event handlers need to be typed with the appropriate event type for type-safe event handling.
<import { ChangeEvent, FormEvent, MouseEvent, KeyboardEvent } from 'react';
interface FormComponentProps {
onSubmit: (data: { email: string; password: string }) => void;
}
const FormComponent = ({ onSubmit }: FormComponentProps) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// Input change event
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
};
// Form submit event
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit({ email, password });
};
// Button click event
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
console.log('Button clicked', e.currentTarget);
};
// Keyboard event
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
console.log('Enter pressed');
}
};
// Inline event handler (type inference works)
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={handleEmailChange}
onKeyDown={handleKeyDown}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit" onClick={handleClick}>
Submit
</button>
</form>
);
};
// Common event types:
// - MouseEvent<HTMLButtonElement>
// - ChangeEvent<HTMLInputElement>
// - ChangeEvent<HTMLTextAreaElement>
// - ChangeEvent<HTMLSelectElement>
// - FormEvent<HTMLFormElement>
// - KeyboardEvent<HTMLInputElement>
// - FocusEvent<HTMLInputElement>
>
HTMLInputElement) for accurate type checking of the event target.
Typing useEffect and useRef
The useEffect hook doesn't require explicit typing, but useRef needs type arguments for proper type safety.
<import { useEffect, useRef } from 'react';
const Component = () => {
// useEffect - no typing needed, but cleanup must match return type
useEffect(() => {
const timer = setTimeout(() => {
console.log('Delayed action');
}, 1000);
// Cleanup function
return () => clearTimeout(timer);
}, []);
// Ref for DOM elements
const inputRef = useRef<HTMLInputElement>(null);
const divRef = useRef<HTMLDivElement>(null);
// Ref for mutable values
const countRef = useRef<number>(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Type-safe ref access
if (inputRef.current) {
inputRef.current.focus();
}
if (divRef.current) {
console.log(divRef.current.offsetHeight);
}
}, []);
const startTimer = () => {
timerRef.current = setTimeout(() => {
countRef.current += 1;
}, 1000);
};
const stopTimer = () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
return (
<div ref={divRef}>
<input ref={inputRef} />
<button onClick={startTimer}>Start</button>
<button onClick={stopTimer}>Stop</button>
</div>
);
};
>
Typing Custom Hooks
Custom hooks follow the same typing patterns as regular functions, with explicit return types for clarity.
<import { useState, useEffect } from 'react';
// Simple custom hook
function useToggle(initialValue: boolean = false): [boolean, () => void] {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(prev => !prev);
return [value, toggle];
}
// Custom hook with options
interface UseFetchOptions {
method?: 'GET' | 'POST';
headers?: Record<string, string>;
}
interface UseFetchReturn<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string, options?: UseFetchOptions): UseFetchReturn<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, options);
const json = await response.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}
// Usage
interface User {
id: number;
name: string;
}
const UserComponent = () => {
const { data, loading, error } = useFetch<User>('/api/user/123');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <div>{data.name}</div>;
};
>
Typing Context API
The Context API requires careful typing to ensure type safety across provider and consumer components.
<import { createContext, useContext, useState, ReactNode } from 'react';
// Define context type
interface User {
id: string;
name: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isAuthenticated: boolean;
}
// Create context with undefined default (ensures provider is used)
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider component
interface AuthProviderProps {
children: ReactNode;
}
export const AuthProvider = ({ children }: AuthProviderProps) => {
const [user, setUser] = useState<User | null>(null);
const login = async (email: string, password: string) => {
// API call logic
const userData = await fetchUser(email, password);
setUser(userData);
};
const logout = () => {
setUser(null);
};
const value: AuthContextType = {
user,
login,
logout,
isAuthenticated: !!user
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
// Custom hook for using context
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// Usage in components
const ProfileComponent = () => {
const { user, logout, isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <div>Please log in</div>;
}
return (
<div>
<h1>{user?.name}</h1>
<button onClick={logout}>Logout</button>
</div>
);
};
>
Typing Component Props with Generics
Generic components allow for flexible, reusable components with type safety.
<interface SelectProps<T> {
options: T[];
value: T;
onChange: (value: T) => void;
getLabel: (option: T) => string;
getValue: (option: T) => string | number;
}
function Select<T>({
options,
value,
onChange,
getLabel,
getValue
}: SelectProps<T>) {
return (
<select
value={getValue(value)}
onChange={(e) => {
const selectedOption = options.find(
(opt) => getValue(opt).toString() === e.target.value
);
if (selectedOption) {
onChange(selectedOption);
}
}}
>
{options.map((option) => (
<option key={getValue(option)} value={getValue(option)}>
{getLabel(option)}
</option>
))}
</select>
);
}
// Usage with different types
interface User {
id: number;
name: string;
}
const UserSelect = () => {
const [selectedUser, setSelectedUser] = useState<User>(users[0]);
return (
<Select
options={users}
value={selectedUser}
onChange={setSelectedUser}
getLabel={(user) => user.name}
getValue={(user) => user.id}
/>
);
};
// Works with primitives too
const StringSelect = () => {
const [selected, setSelected] = useState('option1');
return (
<Select
options={['option1', 'option2', 'option3']}
value={selected}
onChange={setSelected}
getLabel={(opt) => opt}
getValue={(opt) => opt}
/>
);
};
>
Typing Higher-Order Components (HOCs)
Higher-Order Components require careful typing to preserve the wrapped component's props.
<import { ComponentType } from 'react';
// Injected props
interface WithLoadingProps {
loading: boolean;
}
// HOC function
function withLoading<P extends object>(
Component: ComponentType<P>
): ComponentType<P & WithLoadingProps> {
return (props: P & WithLoadingProps) => {
const { loading, ...rest } = props;
if (loading) {
return <div>Loading...</div>;
}
return <Component {...(rest as P)} />;
};
}
// Usage
interface UserListProps {
users: User[];
onUserClick: (user: User) => void;
}
const UserList = ({ users, onUserClick }: UserListProps) => {
return (
<ul>
{users.map(user => (
<li key={user.id} onClick={() => onUserClick(user)}>
{user.name}
</li>
))}
</ul>
);
};
const UserListWithLoading = withLoading(UserList);
// Component now requires both original props and loading prop
<UserListWithLoading
users={users}
onUserClick={handleClick}
loading={isLoading}
/>
>
- Accepts a list of items via props
- Manages selected item state with useState
- Uses useEffect to fetch data when component mounts
- Handles click events with proper typing
- Provides a custom hook to access the selected item