TypeScript and React are a powerful combination for building type-safe, maintainable web applications. TypeScript provides autocomplete, type checking, and excellent developer experience when working with React components, props, state, and hooks. Let's master TypeScript with React!
Function Components and Props
Function components can be typed by defining a props interface and typing the component parameters.
import React from 'react';
// Define props interface
interface UserProps {
name: string;
age: number;
email: string;
role?: string; // Optional prop
}
// Method 1: Inline props typing
function User({ name, age, email, role = "user" }: UserProps) {
return (
<div className="user-card">
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p>
<p>Role: {role}</p>
</div>
);
}
// Method 2: Using React.FC (Function Component)
const UserFC: React.FC<UserProps> = ({ name, age, email, role = "user" }) => {
return (
<div className="user-card">
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p>
<p>Role: {role}</p>
</div>
);
};
// Usage
function App() {
return (
<div>
<User
name="Yusuf Ibrahim"
age={25}
email="yusuf@example.com"
role="admin"
/>
{/* Optional prop can be omitted */}
<User
name="Amina Hassan"
age={30}
email="amina@example.com"
/>
</div>
);
}
export default User;💡 React.FC vs Regular Function
React.FC automatically includes children type and provides better type inference in some cases. However, modern React developers often prefer regular function components with inline props typing for simplicity. Both approaches work well!
Children Prop
import React from 'react';
// Using React.ReactNode for children
interface CardProps {
title: string;
children: React.ReactNode; // Can be any valid React child
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h3>{title}</h3>
<div className="card-content">
{children}
</div>
</div>
);
}
// More specific children types
interface ButtonProps {
children: string; // Only string children
onClick: () => void;
}
function Button({ children, onClick }: ButtonProps) {
return <button onClick={onClick}>{children}</button>;
}
// Multiple children of specific type
interface ListProps {
children: React.ReactElement<ItemProps>[]; // Array of Item components
}
interface ItemProps {
text: string;
}
function Item({ text }: ItemProps) {
return <li>{text}</li>;
}
function List({ children }: ListProps) {
return <ul>{children}</ul>;
}
// Usage
function App() {
return (
<div>
<Card title="User Information">
<p>Name: Yusuf Ibrahim</p>
<p>Email: yusuf@example.com</p>
<Button onClick={() => console.log("Clicked")}>
View Profile
</Button>
</Card>
<List>
<Item text="First item" />
<Item text="Second item" />
<Item text="Third item" />
</List>
</div>
);
}
export default Card;Event Handlers
import React, { useState } from 'react';
interface FormProps {
onSubmit: (data: FormData) => void;
}
interface FormData {
username: string;
email: string;
age: number;
}
function Form({ onSubmit }: FormProps) {
const [username, setUsername] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [age, setAge] = useState<number>(0);
// Click event
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
onSubmit({ username, email, age });
};
// Input change event
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
};
// Select change event
const handleAgeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setAge(Number(e.target.value));
};
// Mouse event
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
console.log("Mouse entered at:", e.clientX, e.clientY);
};
// Keyboard event
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
console.log("Enter pressed");
}
};
return (
<div onMouseEnter={handleMouseEnter}>
<form onSubmit={handleSubmit}>
<input
type="text"
value={username}
onChange={handleUsernameChange}
onKeyPress={handleKeyPress}
placeholder="Username"
/>
<input
type="email"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
}}
placeholder="Email"
/>
<select value={age} onChange={handleAgeChange}>
<option value={0}>Select age</option>
<option value={18}>18</option>
<option value={25}>25</option>
<option value={30}>30</option>
</select>
<button type="submit">Submit</button>
</form>
</div>
);
}
// Usage
function App() {
const handleFormSubmit = (data: FormData) => {
console.log("Form submitted:", data);
};
return <Form onSubmit={handleFormSubmit} />;
}
export default Form;useState Hook
import React, { useState } from 'react';
// Type inference from initial value
function Counter() {
// TypeScript infers count is number
const [count, setCount] = useState(0);
// TypeScript infers text is string
const [text, setText] = useState("Hello");
// TypeScript infers isActive is boolean
const [isActive, setIsActive] = useState(true);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(count - 1)}>Decrement</button>
</div>
);
}
// Explicit type parameter
interface User {
id: number;
name: string;
email: string;
}
function UserProfile() {
// Explicit type when initial value is null or undefined
const [user, setUser] = useState<User | null>(null);
// Array type
const [users, setUsers] = useState<User[]>([]);
// Complex type
const [settings, setSettings] = useState<{
theme: "light" | "dark";
notifications: boolean;
language: string;
}>({
theme: "light",
notifications: true,
language: "en"
});
const loadUser = () => {
const newUser: User = {
id: 1,
name: "Yusuf Ibrahim",
email: "yusuf@example.com"
};
setUser(newUser);
};
const addUser = (newUser: User) => {
setUsers(prevUsers => [...prevUsers, newUser]);
};
const updateSettings = () => {
setSettings(prev => ({
...prev,
theme: prev.theme === "light" ? "dark" : "light"
}));
};
return (
<div>
{user ? (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
) : (
<button onClick={loadUser}>Load User</button>
)}
<p>Theme: {settings.theme}</p>
<button onClick={updateSettings}>Toggle Theme</button>
<p>Total Users: {users.length}</p>
</div>
);
}
export default Counter;useEffect Hook
import React, { useState, useEffect } from 'react';
interface Product {
id: number;
name: string;
price: number;
}
function DataFetcher() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Effect with cleanup
useEffect(() => {
let isMounted = true;
const fetchProducts = async () => {
try {
setLoading(true);
const response = await fetch('/api/products');
const data: Product[] = await response.json();
if (isMounted) {
setProducts(data);
setError(null);
}
} catch (err) {
if (isMounted) {
setError((err as Error).message);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchProducts();
// Cleanup function
return () => {
isMounted = false;
};
}, []); // Empty dependency array - run once
// Effect with dependencies
const [searchTerm, setSearchTerm] = useState<string>("");
const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);
useEffect(() => {
const filtered = products.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase())
);
setFilteredProducts(filtered);
}, [products, searchTerm]); // Re-run when these change
// Effect with interval cleanup
const [timer, setTimer] = useState<number>(0);
useEffect(() => {
const intervalId = setInterval(() => {
setTimer(prev => prev + 1);
}, 1000);
// Cleanup interval
return () => {
clearInterval(intervalId);
};
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div>
<p>Timer: {timer}s</p>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
/>
<div>
{filteredProducts.map(product => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>₦{product.price.toLocaleString()}</p>
</div>
))}
</div>
</div>
);
}
export default DataFetcher;useRef Hook
import React, { useRef, useEffect } from 'react';
function InputFocus() {
// Ref for DOM element
const inputRef = useRef<HTMLInputElement>(null);
// Ref for mutable value
const countRef = useRef<number>(0);
// Ref for timeout
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// Focus input on mount
if (inputRef.current) {
inputRef.current.focus();
}
}, []);
const handleClick = () => {
// Access DOM element
if (inputRef.current) {
console.log("Input value:", inputRef.current.value);
inputRef.current.select();
}
// Update mutable value (doesn't cause re-render)
countRef.current += 1;
console.log("Clicks:", countRef.current);
};
const startTimer = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
console.log("Timer finished");
}, 3000);
};
useEffect(() => {
// Cleanup timeout on unmount
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return (
<div>
<input
ref={inputRef}
type="text"
placeholder="Type here..."
/>
<button onClick={handleClick}>Focus & Select</button>
<button onClick={startTimer}>Start Timer</button>
</div>
);
}
// Ref forwarding
interface CustomInputProps {
placeholder?: string;
}
const CustomInput = React.forwardRef<HTMLInputElement, CustomInputProps>(
({ placeholder }, ref) => {
return (
<input
ref={ref}
type="text"
placeholder={placeholder}
className="custom-input"
/>
);
}
);
CustomInput.displayName = "CustomInput";
function App() {
const customInputRef = useRef<HTMLInputElement>(null);
const focusCustomInput = () => {
customInputRef.current?.focus();
};
return (
<div>
<CustomInput ref={customInputRef} placeholder="Custom input" />
<button onClick={focusCustomInput}>Focus Custom Input</button>
</div>
);
}
export default InputFocus;Custom Hooks
import { useState, useEffect } from 'react';
// Custom hook with generic type
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Get initial value from localStorage or use provided initial value
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Update localStorage when value changes
const setValue = (value: T | ((prev: T) => T)) => {
try {
// Handle function updater
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
};
return [storedValue, setValue];
}
// Usage
function App() {
const [name, setName] = useLocalStorage<string>("name", "");
const [age, setAge] = useLocalStorage<number>("age", 0);
const [user, setUser] = useLocalStorage<{ id: number; name: string } | null>(
"user",
null
);
return (
<div>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
placeholder="Age"
/>
<button
onClick={() => setUser({ id: 1, name: "Yusuf" })}
>
Set User
</button>
</div>
);
}
export default useLocalStorage;import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: T = await response.json();
if (isMounted) {
setData(result);
setError(null);
}
} catch (err) {
if (isMounted) {
setError((err as Error).message);
setData(null);
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
if (!users) return <div>No data</div>;
return (
<div>
{users.map(user => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
);
}
export default useFetch;Practical Examples
Example 1: Todo List with TypeScript
import React, { useState } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<div className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}
>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
}
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [inputText, setInputText] = useState<string>("");
const addTodo = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!inputText.trim()) return;
const newTodo: Todo = {
id: Date.now(),
text: inputText,
completed: false,
createdAt: new Date()
};
setTodos(prevTodos => [...prevTodos, newTodo]);
setInputText("");
};
const toggleTodo = (id: number) => {
setTodos(prevTodos =>
prevTodos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
const deleteTodo = (id: number) => {
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
};
const completedCount = todos.filter(t => t.completed).length;
const totalCount = todos.length;
return (
<div className="todo-list">
<h1>Todo List</h1>
<form onSubmit={addTodo}>
<input
type="text"
value={inputText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
}}
placeholder="Add a todo..."
/>
<button type="submit">Add</button>
</form>
<div className="stats">
<p>
Completed: {completedCount} / {totalCount}
</p>
</div>
<div className="todos">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
))}
</div>
</div>
);
}
export default TodoList;Example 2: Form with Validation
import React, { useState } from 'react';
interface FormData {
username: string;
email: string;
password: string;
confirmPassword: string;
}
interface FormErrors {
username?: string;
email?: string;
password?: string;
confirmPassword?: string;
}
type FormField = keyof FormData;
function RegistrationForm() {
const [formData, setFormData] = useState<FormData>({
username: "",
email: "",
password: "",
confirmPassword: ""
});
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<FormField, boolean>>({
username: false,
email: false,
password: false,
confirmPassword: false
});
const validateField = (field: FormField, value: string): string | undefined => {
switch (field) {
case "username":
if (!value) return "Username is required";
if (value.length < 3) return "Username must be at least 3 characters";
return undefined;
case "email":
if (!value) return "Email is required";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return "Invalid email format";
}
return undefined;
case "password":
if (!value) return "Password is required";
if (value.length < 8) return "Password must be at least 8 characters";
return undefined;
case "confirmPassword":
if (!value) return "Please confirm password";
if (value !== formData.password) return "Passwords do not match";
return undefined;
default:
return undefined;
}
};
const handleChange = (field: FormField) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
const value = e.target.value;
setFormData(prev => ({
...prev,
[field]: value
}));
// Validate on change if field was touched
if (touched[field]) {
const error = validateField(field, value);
setErrors(prev => ({
...prev,
[field]: error
}));
}
};
const handleBlur = (field: FormField) => () => {
setTouched(prev => ({
...prev,
[field]: true
}));
const error = validateField(field, formData[field]);
setErrors(prev => ({
...prev,
[field]: error
}));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// Validate all fields
const newErrors: FormErrors = {};
let hasErrors = false;
(Object.keys(formData) as FormField[]).forEach(field => {
const error = validateField(field, formData[field]);
if (error) {
newErrors[field] = error;
hasErrors = true;
}
});
setErrors(newErrors);
if (!hasErrors) {
console.log("Form submitted:", formData);
// Submit to API
}
};
return (
<form onSubmit={handleSubmit} className="registration-form">
<h2>Register</h2>
<div className="form-group">
<input
type="text"
value={formData.username}
onChange={handleChange("username")}
onBlur={handleBlur("username")}
placeholder="Username"
/>
{touched.username && errors.username && (
<span className="error">{errors.username}</span>
)}
</div>
<div className="form-group">
<input
type="email"
value={formData.email}
onChange={handleChange("email")}
onBlur={handleBlur("email")}
placeholder="Email"
/>
{touched.email && errors.email && (
<span className="error">{errors.email}</span>
)}
</div>
<div className="form-group">
<input
type="password"
value={formData.password}
onChange={handleChange("password")}
onBlur={handleBlur("password")}
placeholder="Password"
/>
{touched.password && errors.password && (
<span className="error">{errors.password}</span>
)}
</div>
<div className="form-group">
<input
type="password"
value={formData.confirmPassword}
onChange={handleChange("confirmPassword")}
onBlur={handleBlur("confirmPassword")}
placeholder="Confirm Password"
/>
{touched.confirmPassword && errors.confirmPassword && (
<span className="error">{errors.confirmPassword}</span>
)}
</div>
<button type="submit">Register</button>
</form>
);
}
export default RegistrationForm;Test Your Knowledge
Type Safety Check
Which React TypeScript component is correctly typed?
// Option A
interface UserProps {
name: string;
age: number;
}
function User({ name, age }: UserProps) {
return <div>{name} is {age} years old</div>;
}
// Option B
function Greeting(props) {
return <h1>Hello, {props.name}</h1>;
}
// Option C
interface ButtonProps {
onClick: () => void;
children: React.ReactNode;
}
const Button: React.FC<ButtonProps> = ({ onClick, children }) => {
return <button onClick={onClick}>{children}</button>;
};
// Option D
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}Common TypeScript Error
interface UserCardProps {
name: string;
age: number;
email: string;
}
function UserCard({ name, age }: UserCardProps) {
return (
<div>
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p> {/* ❌ Error: email not destructured */}
</div>
);
}
// Using the component with wrong prop types
function App() {
return (
<UserCard
name="Yusuf"
age="25" {/* ❌ Error: age should be number, not string */}
// email prop is missing! ❌ Error
/>
);
}❌ Property 'email' does not exist. Type 'string' is not assignable to type 'number'. Property 'email' is missing in type.
What's Wrong?
Props must match the interface exactly. All required props must be provided, types must match, and destructured props must include all used properties.
interface UserCardProps {
name: string;
age: number;
email: string;
}
// ✅ Destructure all props
function UserCard({ name, age, email }: UserCardProps) {
return (
<div>
<h2>{name}</h2>
<p>Age: {age}</p>
<p>Email: {email}</p> {/* ✅ Now accessible */}
</div>
);
}
// ✅ Pass all required props with correct types
function App() {
return (
<UserCard
name="Yusuf"
age={25} {/* ✅ Number, not string */}
email="yusuf@example.com" {/* ✅ All props provided */}
/>
);
}
// Optional props
interface ButtonProps {
text: string;
variant?: "primary" | "secondary"; // Optional
onClick?: () => void; // Optional
}
function Button({ text, variant = "primary", onClick }: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
onClick={onClick}
>
{text}
</button>
);
}
// ✅ Only required props needed
<Button text="Click me" />
// ✅ Can provide optional props
<Button
text="Submit"
variant="primary"
onClick={() => console.log("Clicked")}
/>Solution: Destructure all props and pass correct types
Best Practices
- Always type component props - use interfaces for clarity
- Use type inference when possible - let TypeScript infer from initial values
- Type event handlers explicitly - prevents runtime errors
- Make optional props explicit with
?operator - Use React.ReactNode for children - most flexible type
- Type custom hooks with generics - reusable and type-safe
- Use proper ref types - HTMLElement types for DOM refs
- Clean up effects - return cleanup functions
import React, { useState, useEffect } from 'react';
// ✅ Good: Clear props interface
interface UserCardProps {
user: {
id: number;
name: string;
};
onEdit?: () => void; // Optional
}
function UserCard({ user, onEdit }: UserCardProps) {
return (
<div>
<h2>{user.name}</h2>
{onEdit && <button onClick={onEdit}>Edit</button>}
</div>
);
}
// ✅ Good: Type inference for simple state
function Counter() {
const [count, setCount] = useState(0); // Inferred as number
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// ✅ Good: Explicit types for complex state
interface Todo {
id: number;
text: string;
}
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
// ...
}
// ✅ Good: Typed event handlers
function Form() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
// ❌ Avoid: Untyped props
function Bad(props: any) { // Don't use 'any'!
return <div>{props.name}</div>;
}
// ❌ Avoid: Missing types for complex state
function AlsoBad() {
const [data, setData] = useState(); // Type is 'undefined'!
// Should be: useState<DataType | null>(null)
}Key Takeaways
- Type component props with interfaces for clarity
- Use
React.FC<Props>or inline prop typing React.ReactNodeis the most flexible children type- Event handlers need explicit types like
React.ChangeEvent - useState infers types from initial values
- Use generic type parameters for complex state
- useRef needs proper HTML element types
- Custom hooks can use generics for reusability
- Always return cleanup functions in useEffect
- TypeScript + React = excellent developer experience
What's Next?
Excellent work! You've mastered TypeScript with React and can now build type-safe React applications. Next, we'll explore Async/Await and Promises—how to type asynchronous code, handle Promises, and work with async/await in TypeScript. This is essential for modern JavaScript development!
Get ready to master asynchronous TypeScript!