React Hooks: useEffect, useContext & Custom Hooks
Master advanced React patterns with hooks for side effects, context management, and reusable logic.
What You'll Learn
- Using useEffect for side effects and data fetching
- Managing global state with useContext
- Creating custom hooks for reusable logic
- Understanding hook dependencies and cleanup
1. useEffect Hook
The useEffect hook lets you perform side effects in functional components. Side effects include data fetching, subscriptions, timers, and manually changing the DOM. Think of it as componentDidMount, componentDidUpdate, and componentWillUnmount combined.
Basic useEffect Example
import { useState, useEffect } from 'react';
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// This runs after component mounts and when userId changes
async function fetchUser() {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
setLoading(false);
}
fetchUser();
}, [userId]); // Dependency array
if (loading) return <div>Loading...</div>;
if (!user) return <div>User not found</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}The dependency array [userId] tells React to re-run the effect only when userId changes. An empty array [] means the effect runs once on mount. No array means it runs after every render.
2. Cleanup Functions
Some effects need cleanup to prevent memory leaks. Return a cleanup function from useEffect to handle subscriptions, timers, or event listeners.
useEffect with Cleanup
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
// Cleanup function
return () => {
clearInterval(interval);
};
}, []); // Empty array = run once on mount
return <div>Elapsed: {seconds}s</div>;
}
// WebSocket example
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);
ws.onmessage = (event) => {
setMessages(prev => [...prev, event.data]);
};
// Cleanup: close connection when component unmounts
return () => {
ws.close();
};
}, [roomId]);
return (
<div>
{messages.map((msg, i) => <p key={i}>{msg}</p>)}
</div>
);
}3. useContext Hook
Context provides a way to pass data through the component tree without manually passing props at every level. It's perfect for themes, user authentication, and global settings.
Creating and Using Context
import { createContext, useContext, useState } from 'react';
// Create context
interface ThemeContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// Provider component
export 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>
);
}
// Custom hook to use the context
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
// Using the context in a component
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}4. Custom Hooks
Custom hooks let you extract component logic into reusable functions. They're just JavaScript functions that use other hooks and follow the naming convention "use[Name]".
Custom Hook Examples
// useFetch - Reusable data fetching hook
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const json = await response.json();
setData(json);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}
fetchData();
}, [url]);
return { data, loading, error };
}
// Usage
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{users?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
// useLocalStorage - Sync state with localStorage
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// Usage
function Settings() {
const [settings, setSettings] = useLocalStorage('userSettings', {
notifications: true,
theme: 'light'
});
return (
<div>
<label>
<input
type="checkbox"
checked={settings.notifications}
onChange={(e) => setSettings({
...settings,
notifications: e.target.checked
})}
/>
Enable notifications
</label>
</div>
);
}5. Real-World Example: Auth Context
Complete Authentication System
import { createContext, useContext, useState, useEffect } from 'react';
interface User {
id: string;
name: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is logged in on mount
async function checkAuth() {
try {
const response = await fetch('/api/auth/me');
if (response.ok) {
const userData = await response.json();
setUser(userData);
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setLoading(false);
}
}
checkAuth();
}, []);
const login = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) throw new Error('Login failed');
const userData = await response.json();
setUser(userData);
};
const logout = () => {
fetch('/api/auth/logout', { method: 'POST' });
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// Protected route component
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <div>Loading...</div>;
if (!user) return <Navigate to="/login" />;
return <>{children}</>;
}Key Takeaways
- useEffect handles side effects and runs after render
- Dependencies control when effects re-run
- Cleanup functions prevent memory leaks
- useContext avoids prop drilling for global state
- Custom hooks extract and reuse stateful logic
- Always handle loading and error states in async operations
Practice Exercises
Challenge Yourself
- 1. useWindowSize: Create a custom hook that tracks window dimensions and updates on resize.
- 2. useDebounce: Build a hook that debounces a value (useful for search inputs).
- 3. Dark Mode: Implement a complete dark mode system using Context and localStorage.
- 4. API Cache: Create a custom hook that caches API responses to avoid duplicate requests.