Skip to main content

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>
);
}
React.FC vs Function Declaration

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.