Module 2 • 60 min read

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. 1. useWindowSize: Create a custom hook that tracks window dimensions and updates on resize.
  2. 2. useDebounce: Build a hook that debounces a value (useful for search inputs).
  3. 3. Dark Mode: Implement a complete dark mode system using Context and localStorage.
  4. 4. API Cache: Create a custom hook that caches API responses to avoid duplicate requests.

Continue Learning

Next: Next.js & TypeScript

Build production-ready applications with Next.js App Router and TypeScript.

Back to Fundamentals

Review React basics: components, props, and state management.