You are viewing a preview of this lesson. Sign in to start learning
Back to React

React Hooks Mastery

Master essential and advanced React hooks for state and side effects

React Hooks Mastery

Master React Hooks with free flashcards and spaced repetition practice. This lesson covers useState, useEffect, custom hooks, performance optimization with useMemo and useCallback, and advanced patternsβ€”essential concepts for building modern, efficient React applications.

Welcome to React Hooks 🎣

React Hooks revolutionized how we write React components. Introduced in React 16.8, Hooks allow you to use state and other React features without writing class components. They provide a more direct API to the React concepts you already know: props, state, context, refs, and lifecycle.

In this comprehensive lesson, you'll learn to leverage Hooks effectively, understand their rules and constraints, avoid common pitfalls, and build custom Hooks to encapsulate reusable logic. By the end, you'll write cleaner, more maintainable React code with confidence.

Core Concepts

What Are Hooks? πŸͺ

Hooks are functions that let you "hook into" React state and lifecycle features from function components. They don't work inside classesβ€”they let you use React without classes.

Key characteristics:

  • Functions that start with use (naming convention)
  • Can only be called at the top level of components
  • Can only be called from React function components or custom Hooks
  • Enable state and side effects in functional components

πŸ’‘ Tip: Think of Hooks as a way to "plug in" React features to your functional components, like plugging appliances into electrical outlets.

The Two Rules of Hooks βš–οΈ

React enforces two critical rules:

πŸ“‹ Rules of Hooks

RuleDescriptionWhy?
1. Top Level OnlyDon't call Hooks inside loops, conditions, or nested functionsReact relies on call order to preserve state between renders
2. React Functions OnlyOnly call Hooks from React function components or custom HooksEnsures Hooks are traceable and manageable by React

⚠️ Common Mistake: Calling Hooks conditionally breaks React's internal state tracking:

// ❌ WRONG - Hook inside condition
if (someCondition) {
  const [state, setState] = useState(0);
}

// βœ… RIGHT - Condition inside Hook logic
const [state, setState] = useState(0);
if (someCondition) {
  setState(1);
}

useState: Managing Component State πŸ“Š

useState is the most fundamental Hook. It adds state to functional components.

Syntax:

const [state, setState] = useState(initialValue);
  • state: Current state value
  • setState: Function to update state
  • initialValue: Initial state (can be a value or a function)

Example with primitive value:

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  );
}

Functional updates: When new state depends on previous state, use a function:

// ❌ Risky - can miss updates in rapid succession
setCount(count + 1);

// βœ… Safe - always uses latest state
setCount(prevCount => prevCount + 1);

Lazy initialization: For expensive initial computations:

// ❌ Runs on every render
const [data, setData] = useState(expensiveComputation());

// βœ… Runs only on mount
const [data, setData] = useState(() => expensiveComputation());

🧠 Mnemonic: "State Updates Safely Every time" - use functional updates (prevState => newState) for safety.

useEffect: Side Effects and Lifecycle πŸ”„

useEffect lets you perform side effects in function components. It serves the same purpose as componentDidMount, componentDidUpdate, and componentWillUnmount combined.

Syntax:

useEffect(() => {
  // Side effect code here
  return () => {
    // Cleanup code (optional)
  };
}, [dependencies]);

Three patterns of useEffect:

PatternDependenciesWhen It RunsUse Case
No deps arrayuseEffect(() => {...})After every renderRarely needed - usually too frequent
Empty depsuseEffect(() => {...}, [])Once on mountData fetching, subscriptions
With depsuseEffect(() => {...}, [a, b])When a or b changesSync with specific values

Data fetching example:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      setLoading(true);
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      
      if (!cancelled) {
        setUser(data);
        setLoading(false);
      }
    }

    fetchUser();

    // Cleanup function prevents state updates after unmount
    return () => {
      cancelled = true;
    };
  }, [userId]); // Re-run when userId changes

  if (loading) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

Subscription example with cleanup:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const socket = connectToRoom(roomId);

    socket.on('message', (msg) => {
      setMessages(prev => [...prev, msg]);
    });

    // Cleanup: disconnect when component unmounts or roomId changes
    return () => {
      socket.disconnect();
    };
  }, [roomId]);

  return <div>{messages.map(m => <p key={m.id}>{m.text}</p>)}</div>;
}

πŸ’‘ Tip: Think of useEffect as "synchronizing" your component with external systems (APIs, subscriptions, DOM).

⚠️ Common Mistake: Missing dependencies causes stale closures:

// ❌ WRONG - count is stale
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1); // Always uses count from first render
  }, 1000);
  return () => clearInterval(timer);
}, []); // Missing 'count' dependency

// βœ… RIGHT - use functional update
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // Always gets latest count
  }, 1000);
  return () => clearInterval(timer);
}, []); // No dependencies needed

useContext: Consuming Context 🌐

useContext provides a cleaner way to consume React Context compared to Context.Consumer.

Syntax:

const value = useContext(MyContext);

Example:

const ThemeContext = React.createContext('light');

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  return <ThemedButton />;
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return <button className={theme}>Click me</button>;
}

Multiple contexts:

function Component() {
  const theme = useContext(ThemeContext);
  const user = useContext(UserContext);
  const locale = useContext(LocaleContext);

  return <div className={theme}>{user.name} - {locale}</div>;
}

useRef: Persisting Values and DOM Access 🎯

useRef creates a mutable reference that persists across renders without causing re-renders when changed.

Two main use cases:

1. Accessing DOM elements:

function TextInputWithFocus() {
  const inputRef = useRef(null);

  const focusInput = () => {
    inputRef.current.focus();
  };

  return (
    <>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus Input</button>
    </>
  );
}

2. Storing mutable values:

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);

  const start = () => {
    if (!intervalRef.current) {
      intervalRef.current = setInterval(() => {
        setSeconds(s => s + 1);
      }, 1000);
    }
  };

  const stop = () => {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

πŸ” Key difference: Changing ref.current doesn't trigger re-renders, unlike setState.

useMemo: Memoizing Expensive Computations ⚑

useMemo caches the result of expensive calculations, recomputing only when dependencies change.

Syntax:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

When to use useMemo:

  • Expensive computations (filtering large arrays, complex calculations)
  • Preventing unnecessary child re-renders (when passing objects/arrays as props)
  • NOT for cheap operations (premature optimization)

Example:

function ProductList({ products, filterText }) {
  // Without useMemo: filters on every render (even unrelated state changes)
  // With useMemo: filters only when products or filterText changes
  const filteredProducts = useMemo(() => {
    console.log('Filtering products...');
    return products.filter(p => 
      p.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [products, filterText]);

  return (
    <ul>
      {filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Preventing object recreation:

function Parent() {
  const [count, setCount] = useState(0);

  // ❌ New object every render - Child re-renders unnecessarily
  const config = { theme: 'dark', locale: 'en' };

  // βœ… Same object reference when dependencies don't change
  const config = useMemo(() => ({ 
    theme: 'dark', 
    locale: 'en' 
  }), []);

  return <Child config={config} />;
}

⚠️ Don't overuse: useMemo has overhead. Only use for genuinely expensive operations.

useCallback: Memoizing Functions πŸ”–

useCallback returns a memoized version of a callback function, changing only when dependencies change.

Syntax:

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

Primary use case: Preventing unnecessary re-renders of child components that depend on callback props.

Example:

function Parent() {
  const [count, setCount] = useState(0);
  const [otherState, setOtherState] = useState(0);

  // ❌ New function every render - Child re-renders even if count unchanged
  const handleClick = () => {
    console.log(count);
  };

  // βœ… Same function reference when count unchanged
  const handleClick = useCallback(() => {
    console.log(count);
  }, [count]);

  return <Child onClick={handleClick} />;
}

const Child = React.memo(({ onClick }) => {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
});

🧠 Remember: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps)

useCallback vs useMemo quick comparison:

HookReturnsUse Case
useMemoMemoized valueExpensive calculations, object/array creation
useCallbackMemoized functionPassing callbacks to optimized children

Custom Hooks: Reusable Logic πŸ› οΈ

Custom Hooks let you extract component logic into reusable functions. They're regular JavaScript functions that can call other Hooks.

Naming convention: Must start with use (e.g., useWindowSize, useFetch)

Example 1: Window size Hook:

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}

// Usage
function Component() {
  const { width, height } = useWindowSize();
  return <div>Window is {width}x{height}</div>;
}

Example 2: Data fetching Hook:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    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();
        
        if (!cancelled) {
          setData(json);
          setError(null);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

Example 3: Local storage Hook:

function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(error);
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(error);
    }
  };

  return [storedValue, setValue];
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  );
}

πŸ’‘ Custom Hook Benefits:

  • Encapsulate complex logic
  • Reuse across components
  • Easier to test in isolation
  • Share stateful logic without render props or HOCs

useReducer: Complex State Management πŸ”€

useReducer is an alternative to useState for complex state logic involving multiple sub-values or when next state depends on previous state.

Syntax:

const [state, dispatch] = useReducer(reducer, initialState);

When to use useReducer:

  • Complex state logic with multiple sub-values
  • State transitions depend on previous state
  • Need to pass dispatch down instead of callbacks
  • State updates involve multiple operations

Example: Todo list:

const initialState = { todos: [], filter: 'all' };

function reducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, { id: Date.now(), text: action.text, done: false }]
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.id ? { ...todo, done: !todo.done } : todo
        )
      };
    case 'DELETE_TODO':
      return {
        ...state,
        todos: state.todos.filter(todo => todo.id !== action.id)
      };
    case 'SET_FILTER':
      return { ...state, filter: action.filter };
    default:
      return state;
  }
}

function TodoApp() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const [input, setInput] = useState('');

  const handleAdd = () => {
    if (input.trim()) {
      dispatch({ type: 'ADD_TODO', text: input });
      setInput('');
    }
  };

  const filteredTodos = state.todos.filter(todo => {
    if (state.filter === 'active') return !todo.done;
    if (state.filter === 'completed') return todo.done;
    return true;
  });

  return (
    <div>
      <input value={input} onChange={e => setInput(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
      <div>
        <button onClick={() => dispatch({ type: 'SET_FILTER', filter: 'all' })}>All</button>
        <button onClick={() => dispatch({ type: 'SET_FILTER', filter: 'active' })}>Active</button>
        <button onClick={() => dispatch({ type: 'SET_FILTER', filter: 'completed' })}>Completed</button>
      </div>
      <ul>
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => dispatch({ type: 'TOGGLE_TODO', id: todo.id })}
            />
            <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => dispatch({ type: 'DELETE_TODO', id: todo.id })}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

🧠 Mental model: Think of useReducer like Redux for a single componentβ€”actions describe "what happened," reducer specifies "how state changes."

Detailed Examples

Example 1: Authentication Flow with Multiple Hooks πŸ”

This example combines useState, useEffect, useContext, and a custom Hook to build a complete authentication system:

// AuthContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';

const AuthContext = createContext(null);

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check for stored token on mount
    const token = localStorage.getItem('token');
    if (token) {
      fetchUserData(token)
        .then(userData => {
          setUser(userData);
          setLoading(false);
        })
        .catch(() => {
          localStorage.removeItem('token');
          setLoading(false);
        });
    } else {
      setLoading(false);
    }
  }, []);

  const login = async (email, password) => {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    });
    
    if (!response.ok) throw new Error('Login failed');
    
    const { token, user } = await response.json();
    localStorage.setItem('token', token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem('token');
    setUser(null);
  };

  const value = { user, loading, login, logout };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

// App.js
function App() {
  return (
    <AuthProvider>
      <Router>
        <Routes>
          <Route path="/login" element={<LoginPage />} />
          <Route path="/dashboard" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
        </Routes>
      </Router>
    </AuthProvider>
  );
}

// ProtectedRoute.js
function ProtectedRoute({ children }) {
  const { user, loading } = useAuth();
  const navigate = useNavigate();

  useEffect(() => {
    if (!loading && !user) {
      navigate('/login');
    }
  }, [user, loading, navigate]);

  if (loading) return <div>Loading...</div>;
  return user ? children : null;
}

// LoginPage.js
function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();
  const navigate = useNavigate();

  const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      await login(email, password);
      navigate('/dashboard');
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}
      <input
        type="email"
        value={email}
        onChange={e => setEmail(e.target.value)}
        placeholder="Email"
      />
      <input
        type="password"
        value={password}
        onChange={e => setPassword(e.target.value)}
        placeholder="Password"
      />
      <button type="submit">Login</button>
    </form>
  );
}

Key patterns demonstrated:

  • Custom Hook (useAuth) encapsulates authentication logic
  • Context provides auth state globally
  • Protected routes redirect unauthenticated users
  • Persistent login via localStorage
  • Loading states during async operations

Example 2: Real-time Data with WebSockets πŸ“‘

Building a custom Hook for WebSocket connections with automatic reconnection:

function useWebSocket(url, options = {}) {
  const [data, setData] = useState(null);
  const [status, setStatus] = useState('connecting');
  const wsRef = useRef(null);
  const reconnectTimeoutRef = useRef(null);
  const { reconnectInterval = 3000, maxReconnectAttempts = 5 } = options;
  const reconnectAttemptsRef = useRef(0);

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) return;

    setStatus('connecting');
    const ws = new WebSocket(url);

    ws.onopen = () => {
      setStatus('connected');
      reconnectAttemptsRef.current = 0;
    };

    ws.onmessage = (event) => {
      try {
        const message = JSON.parse(event.data);
        setData(message);
      } catch (err) {
        console.error('Failed to parse message:', err);
      }
    };

    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
      setStatus('error');
    };

    ws.onclose = () => {
      setStatus('disconnected');
      wsRef.current = null;

      // Attempt reconnection
      if (reconnectAttemptsRef.current < maxReconnectAttempts) {
        reconnectAttemptsRef.current++;
        reconnectTimeoutRef.current = setTimeout(() => {
          connect();
        }, reconnectInterval);
      } else {
        setStatus('failed');
      }
    };

    wsRef.current = ws;
  }, [url, reconnectInterval, maxReconnectAttempts]);

  const send = useCallback((message) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message));
    } else {
      console.warn('WebSocket not connected');
    }
  }, []);

  const disconnect = useCallback(() => {
    if (reconnectTimeoutRef.current) {
      clearTimeout(reconnectTimeoutRef.current);
    }
    if (wsRef.current) {
      wsRef.current.close();
      wsRef.current = null;
    }
  }, []);

  useEffect(() => {
    connect();
    return () => {
      disconnect();
    };
  }, [connect, disconnect]);

  return { data, status, send, disconnect, reconnect: connect };
}

// Usage
function LiveChat({ roomId }) {
  const { data: message, status, send } = useWebSocket(`wss://api.example.com/chat/${roomId}`);
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');

  useEffect(() => {
    if (message) {
      setMessages(prev => [...prev, message]);
    }
  }, [message]);

  const handleSend = () => {
    if (input.trim()) {
      send({ type: 'message', text: input });
      setInput('');
    }
  };

  return (
    <div>
      <div className="status">Status: {status}</div>
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i}>{msg.user}: {msg.text}</div>
        ))}
      </div>
      <input
        value={input}
        onChange={e => setInput(e.target.value)}
        onKeyPress={e => e.key === 'Enter' && handleSend()}
        disabled={status !== 'connected'}
      />
      <button onClick={handleSend} disabled={status !== 'connected'}>
        Send
      </button>
    </div>
  );
}

Advanced patterns:

  • useRef stores WebSocket instance without causing re-renders
  • useCallback prevents function recreation
  • Automatic reconnection with exponential backoff
  • Cleanup prevents memory leaks
  • Status tracking for UI feedback

Example 3: Optimized List with Virtualization πŸ“œ

Using useMemo and useCallback to optimize a large list component:

function VirtualizedList({ items, itemHeight, containerHeight }) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);

  // Calculate visible range
  const { startIndex, endIndex, totalHeight } = useMemo(() => {
    const startIndex = Math.floor(scrollTop / itemHeight);
    const endIndex = Math.min(
      items.length - 1,
      Math.ceil((scrollTop + containerHeight) / itemHeight)
    );
    const totalHeight = items.length * itemHeight;
    
    return { startIndex, endIndex, totalHeight };
  }, [scrollTop, itemHeight, containerHeight, items.length]);

  // Slice visible items
  const visibleItems = useMemo(() => {
    return items.slice(startIndex, endIndex + 1);
  }, [items, startIndex, endIndex]);

  // Memoize scroll handler
  const handleScroll = useCallback((e) => {
    setScrollTop(e.target.scrollTop);
  }, []);

  // Calculate offset for positioning
  const offsetY = startIndex * itemHeight;

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{
        height: containerHeight,
        overflow: 'auto',
        position: 'relative'
      }}
    >
      <div style={{ height: totalHeight }}>
        <div
          style={{
            transform: `translateY(${offsetY}px)`,
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0
          }}
        >
          {visibleItems.map((item, index) => (
            <VirtualizedItem
              key={startIndex + index}
              item={item}
              height={itemHeight}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

const VirtualizedItem = React.memo(({ item, height }) => {
  return (
    <div style={{ height, borderBottom: '1px solid #ccc', padding: '8px' }}>
      {item.title}
    </div>
  );
});

// Usage
function App() {
  const items = useMemo(() => {
    return Array.from({ length: 10000 }, (_, i) => ({
      id: i,
      title: `Item ${i + 1}`
    }));
  }, []);

  return (
    <VirtualizedList
      items={items}
      itemHeight={50}
      containerHeight={600}
    />
  );
}

Performance optimizations:

  • Only renders visible items (windowing)
  • useMemo prevents recalculation of visible range
  • React.memo prevents unnecessary child re-renders
  • useCallback stabilizes event handler reference
  • Transform for GPU-accelerated positioning

Example 4: Form Validation with Custom Hook βœ…

A reusable form validation Hook:

function useForm(initialValues, validationRules) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [touched, setTouched] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // Validate single field
  const validateField = useCallback((name, value) => {
    const rules = validationRules[name];
    if (!rules) return null;

    for (const rule of rules) {
      const error = rule(value, values);
      if (error) return error;
    }
    return null;
  }, [validationRules, values]);

  // Validate all fields
  const validateForm = useCallback(() => {
    const newErrors = {};
    let isValid = true;

    Object.keys(validationRules).forEach(name => {
      const error = validateField(name, values[name]);
      if (error) {
        newErrors[name] = error;
        isValid = false;
      }
    });

    setErrors(newErrors);
    return isValid;
  }, [validateField, validationRules, values]);

  const handleChange = useCallback((name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    
    // Validate on change if already touched
    if (touched[name]) {
      const error = validateField(name, value);
      setErrors(prev => ({
        ...prev,
        [name]: error || undefined
      }));
    }
  }, [touched, validateField]);

  const handleBlur = useCallback((name) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    
    const error = validateField(name, values[name]);
    setErrors(prev => ({
      ...prev,
      [name]: error || undefined
    }));
  }, [validateField, values]);

  const handleSubmit = useCallback(async (onSubmit) => {
    // Mark all fields as touched
    const allTouched = Object.keys(validationRules).reduce(
      (acc, key) => ({ ...acc, [key]: true }),
      {}
    );
    setTouched(allTouched);

    if (validateForm()) {
      setIsSubmitting(true);
      try {
        await onSubmit(values);
      } finally {
        setIsSubmitting(false);
      }
    }
  }, [validateForm, validationRules, values]);

  const reset = useCallback(() => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
    setIsSubmitting(false);
  }, [initialValues]);

  return {
    values,
    errors,
    touched,
    isSubmitting,
    handleChange,
    handleBlur,
    handleSubmit,
    reset
  };
}

// Validation rules
const required = (value) => {
  return value ? null : 'This field is required';
};

const minLength = (min) => (value) => {
  return value.length >= min ? null : `Must be at least ${min} characters`;
};

const email = (value) => {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : 'Invalid email';
};

const matchField = (fieldName) => (value, allValues) => {
  return value === allValues[fieldName] ? null : 'Fields do not match';
};

// Usage
function SignupForm() {
  const form = useForm(
    { email: '', password: '', confirmPassword: '' },
    {
      email: [required, email],
      password: [required, minLength(8)],
      confirmPassword: [required, matchField('password')]
    }
  );

  const onSubmit = async (values) => {
    await fetch('/api/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values)
    });
  };

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      form.handleSubmit(onSubmit);
    }}>
      <div>
        <input
          type="email"
          value={form.values.email}
          onChange={(e) => form.handleChange('email', e.target.value)}
          onBlur={() => form.handleBlur('email')}
        />
        {form.touched.email && form.errors.email && (
          <span className="error">{form.errors.email}</span>
        )}
      </div>

      <div>
        <input
          type="password"
          value={form.values.password}
          onChange={(e) => form.handleChange('password', e.target.value)}
          onBlur={() => form.handleBlur('password')}
        />
        {form.touched.password && form.errors.password && (
          <span className="error">{form.errors.password}</span>
        )}
      </div>

      <div>
        <input
          type="password"
          value={form.values.confirmPassword}
          onChange={(e) => form.handleChange('confirmPassword', e.target.value)}
          onBlur={() => form.handleBlur('confirmPassword')}
        />
        {form.touched.confirmPassword && form.errors.confirmPassword && (
          <span className="error">{form.errors.confirmPassword}</span>
        )}
      </div>

      <button type="submit" disabled={form.isSubmitting}>
        {form.isSubmitting ? 'Submitting...' : 'Sign Up'}
      </button>
    </form>
  );
}

Features demonstrated:

  • Reusable validation logic
  • Touch tracking (only show errors after user interaction)
  • Real-time validation after first blur
  • Composable validation rules
  • Submit state management
  • Form reset capability

Common Mistakes to Avoid ⚠️

1. Calling Hooks Conditionally

// ❌ WRONG - Hook call order changes
if (condition) {
  const [state, setState] = useState(0);
}

// βœ… RIGHT - Condition inside Hook logic
const [state, setState] = useState(0);
if (condition) {
  // Use state here
}

2. Missing useEffect Dependencies

// ❌ WRONG - Stale closure
useEffect(() => {
  console.log(count); // Always logs initial count
}, []); // Missing 'count' dependency

// βœ… RIGHT - Include all dependencies
useEffect(() => {
  console.log(count);
}, [count]);

3. Not Cleaning Up useEffect

// ❌ WRONG - Memory leak
useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);
  // No cleanup!
}, []);

// βœ… RIGHT - Clean up on unmount
useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick');
  }, 1000);
  return () => clearInterval(timer);
}, []);

4. Overusing useMemo/useCallback

// ❌ WRONG - Premature optimization
const sum = useMemo(() => a + b, [a, b]); // Simple calculation doesn't need memoization

// βœ… RIGHT - Only for expensive operations
const filtered = useMemo(() => {
  return hugeArray.filter(complexPredicate);
}, [hugeArray]);

5. Mutating State Directly

// ❌ WRONG - Direct mutation
const [items, setItems] = useState([]);
items.push(newItem); // Doesn't trigger re-render!

// βœ… RIGHT - Create new array
setItems([...items, newItem]);
// or
setItems(prev => [...prev, newItem]);

6. Setting State During Render

// ❌ WRONG - Infinite loop
function Component() {
  const [count, setCount] = useState(0);
  setCount(count + 1); // Called every render!
  return <div>{count}</div>;
}

// βœ… RIGHT - Set state in event handlers or effects
function Component() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    setCount(1);
  }, []);
  
  return <div>{count}</div>;
}

7. Forgetting Functional Updates

// ❌ WRONG - Can miss updates
setCount(count + 1);
setCount(count + 1); // Still only increments by 1!

// βœ… RIGHT - Use functional form
setCount(c => c + 1);
setCount(c => c + 1); // Correctly increments by 2

8. Using Index as Key in Lists

// ❌ WRONG - Causes bugs with reordering
items.map((item, index) => <div key={index}>{item}</div>)

// βœ… RIGHT - Use stable unique identifier
items.map(item => <div key={item.id}>{item}</div>)

Key Takeaways 🎯

πŸ“‹ React Hooks Quick Reference

HookPurposeKey Point
useStateAdd state to componentsUse functional updates for prev-dependent logic
useEffectHandle side effectsAlways clean up subscriptions/timers
useContextConsume contextCleaner than Context.Consumer
useRefPersist values/DOM refsDoesn't trigger re-renders
useMemoMemoize valuesOnly for expensive computations
useCallbackMemoize functionsPrevent child re-renders
useReducerComplex state logicAlternative to useState for complex state
Custom HooksReusable logicMust start with 'use'

Golden Rules:

  1. βœ… Always call Hooks at the top level
  2. βœ… Only call Hooks from React functions
  3. βœ… Include all dependencies in useEffect
  4. βœ… Clean up effects that need it
  5. βœ… Use functional updates when next state depends on previous
  6. βœ… Extract reusable logic into custom Hooks
  7. βœ… Memoize only when necessary
  8. βœ… Keep components focused and simple

Performance Tips:

  • πŸš€ Use React.memo for expensive child components
  • πŸš€ Split state to avoid unnecessary re-renders
  • πŸš€ Use useCallback for callbacks passed to memoized children
  • πŸš€ Use useMemo for expensive calculations
  • πŸš€ Consider code splitting for large components

Testing Hooks:

  • Use @testing-library/react-hooks for isolated Hook testing
  • Test custom Hooks separately from components
  • Mock external dependencies (APIs, timers)
  • Test cleanup functions
  • Verify correct dependency arrays

πŸ“š Further Study

  1. React Official Hooks Documentation: https://react.dev/reference/react - Comprehensive guide with all built-in Hooks
  2. useHooks Collection: https://usehooks.com/ - Curated collection of useful custom Hooks with examples
  3. React Hooks Testing Library: https://github.com/testing-library/react-hooks-testing-library - Best practices for testing Hooks

πŸŽ‰ Congratulations! You've mastered React Hooks. You now understand how to manage state, handle side effects, optimize performance, and create reusable custom Hooks. Practice these patterns in your projects, and you'll write cleaner, more maintainable React code. Remember: Hooks are just functionsβ€”don't overthink them, but do respect their rules!