Module 13: TypeScript with React
Learn how to build type-safe React applications with TypeScript, covering components, hooks, props, events, and common patterns.
1. Setup React with TypeScript
# Create new React app with TypeScript
npx create-react-app my-app --template typescript
# Or with Vite
npm create vite@latest my-app -- --template react-ts
2. Function Components
Basic Component
import React from "react";
interface GreetingProps {
name: string;
age?: number;
}
const Greeting: React.FC<GreetingProps> = ({ name, age }) => {
return (
<div>
<h1>Hello, {name}!</h1>
{age && <p>Age: {age}</p>}
</div>
);
};
export default Greeting;
Component Without React.FC
interface UserCardProps {
name: string;
email: string;
onEdit: () => void;
}
function UserCard({ name, email, onEdit }: UserCardProps) {
return (
<div className="user-card">
<h2>{name}</h2>
<p>{email}</p>
<button onClick={onEdit}>Edit</button>
</div>
);
}
Modern React recommends function declarations over React.FC as they provide better type inference for children and generics.
3. Props with Children
interface ContainerProps {
children: React.ReactNode;
className?: string;
}
function Container({ children, className }: ContainerProps) {
return <div className={className}>{children}</div>;
}
// Usage
<Container className="main">
<h1>Title</h1>
<p>Content</p>
</Container>
Children as Function (Render Props)
interface DataFetcherProps<T> {
url: string;
children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = React.useState<T | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
// Fetch logic...
return <>{children(data, loading, error)}</>;
}
// Usage
<DataFetcher<User> url="/api/user">
{(user, loading, error) => {
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>Welcome, {user?.name}</p>;
}}
</DataFetcher>
4. Event Handlers
interface FormProps {
onSubmit: (data: { name: string; email: string }) => void;
}
function UserForm({ onSubmit }: FormProps) {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
onSubmit({
name: formData.get("name") as string,
email: formData.get("email") as string
});
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
console.log("Button clicked", event.currentTarget);
};
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
console.log("Input changed", event.target.value);
};
return (
<form onSubmit={handleSubmit}>
<input name="name" onChange={handleChange} />
<input name="email" type="email" />
<button onClick={handleClick}>Submit</button>
</form>
);
}
Common Event Types
React.FormEvent<HTMLFormElement>
React.MouseEvent<HTMLButtonElement>
React.ChangeEvent<HTMLInputElement>
React.KeyboardEvent<HTMLInputElement>
React.FocusEvent<HTMLInputElement>
React.ClipboardEvent<HTMLInputElement>
5. useState Hook
import { useState } from "react";
function Counter() {
// Type inferred automatically
const [count, setCount] = useState(0);
// Explicit type
const [user, setUser] = useState<User | null>(null);
// With initial state function
const [items, setItems] = useState<string[]>(() => {
return JSON.parse(localStorage.getItem("items") || "[]");
});
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
6. useEffect Hook
import { useEffect } from "react";
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) {
setUser(data);
}
}
fetchUser();
// Cleanup function
return () => {
cancelled = true;
};
}, [userId]);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
7. useRef Hook
import { useRef, useEffect } from "react";
function TextInput() {
// DOM element ref
const inputRef = useRef<HTMLInputElement>(null);
// Mutable value ref
const countRef = useRef(0);
useEffect(() => {
// Focus input on mount
inputRef.current?.focus();
}, []);
const handleClick = () => {
countRef.current += 1;
console.log("Clicked", countRef.current, "times");
};
return (
<>
<input ref={inputRef} type="text" />
<button onClick={handleClick}>Click me</button>
</>
);
}
8. useContext Hook
import { createContext, useContext, useState } from "react";
interface ThemeContextType {
theme: "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = () => {
setTheme(prev => prev === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}
// Usage
function App() {
const { theme, toggleTheme } = useTheme();
return (
<div className={theme}>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
9. useReducer Hook
import { useReducer } from "react";
interface State {
count: number;
error: string | null;
}
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" }
| { type: "error"; payload: string };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return { ...state, count: state.count + 1, error: null };
case "decrement":
return { ...state, count: state.count - 1, error: null };
case "reset":
return { count: 0, error: null };
case "error":
return { ...state, error: action.payload };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0, error: null });
return (
<div>
<p>Count: {state.count}</p>
{state.error && <p>Error: {state.error}</p>}
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}
10. Custom Hooks
import { useState, useEffect } from "react";
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const response = await fetch(url);
const result = await response.json();
setData(result);
setError(null);
} 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;
email: string;
}
function UserList() {
const { data, loading, error, refetch } = useFetch<User[]>("/api/users");
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<button onClick={refetch}>Refresh</button>
{data?.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
11. Generic Components
interface ListProps<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// Usage
interface User {
id: number;
name: string;
}
function App() {
const users: User[] = [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" }
];
return (
<List
items={users}
renderItem={user => <span>{user.name}</span>}
keyExtractor={user => user.id}
/>
);
}
12. forwardRef
import { forwardRef } from "react";
interface InputProps {
label: string;
placeholder?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, placeholder }, ref) => {
return (
<div>
<label>{label}</label>
<input ref={ref} placeholder={placeholder} />
</div>
);
}
);
Input.displayName = "Input";
// Usage
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
console.log(inputRef.current?.value);
};
return (
<div>
<Input ref={inputRef} label="Name" />
<button onClick={handleSubmit}>Submit</button>
</div>
);
}
Key Takeaways
✅ Use interface for component props
✅ Type event handlers with React event types
✅ useState with generic for complex types
✅ Custom hooks for reusable logic
✅ useContext for global state management
✅ Generic components for flexible, reusable UI
✅ forwardRef for ref forwarding
Practice Exercises
Exercise 1: Todo List
interface Todo {
id: number;
text: string;
completed: boolean;
}
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
// Implement add, toggle, delete
}
Exercise 2: Form with Validation
Create a form component with TypeScript validation.
Exercise 3: Data Grid
Build a generic data grid component with sorting and filtering.
Next Steps
In Module 14, we'll explore TypeScript with Node.js, covering Express, async patterns, and building type-safe backend applications.