Blog & Insights

Thoughts, tutorials, and explorations at the intersection of mathematics, visualization, and web development.

React Hooks: A Deep Dive

Published: March 30, 2025Category: React

React Hooks: A Deep Dive

React Hooks revolutionized the way we write React components when they were introduced in React 16.8. They allow you to use state and other React features without writing a class component, making your code more concise, easier to understand, and simpler to test.

Introduction to React Hooks

Before hooks, React components came in two flavors:

  • Class components with state and lifecycle methods
  • Functional components that were stateless

Hooks brought state management and lifecycle features to functional components, allowing developers to write more concise and reusable code.

React Hooks were introduced in React 16.8, released in February 2019, and have since become the recommended way to write React components.

The Basic Hooks

useState: Managing Component State

The useState hook allows functional components to manage state:

1import React, { useState } from 'react';
2
3function Counter() {
4  // Declare a state variable 'count' with initial value 0
5  const [count, setCount] = useState(0);
6  
7  return (
8    <div>
9      <p>You clicked {count} times</p>
10      <button onClick={() => setCount(count + 1)}>
11        Click me
12      </button>
13    </div>
14  );
15}
16

Key points about useState:

  • Returns a pair: the current state value and a function to update it
  • The update function can take a new value or a function that receives the previous state
  • State updates trigger re-renders

useEffect: Side Effects in Functional Components

The useEffect hook handles side effects in functional components:

1import React, { useState, useEffect } from 'react';
2
3function DocumentTitleUpdater() {
4  const [count, setCount] = useState(0);
5  
6  // Update document title using the browser API
7  useEffect(() => {
8    document.title = `You clicked ${count} times`;
9    
10    // Optional cleanup function
11    return () => {
12      document.title = 'React App';
13    };
14  }, [count]); // Only re-run if count changes
15  
16  return (
17    <div>
18      <p>You clicked {count} times</p>
19      <button onClick={() => setCount(count + 1)}>
20        Click me
21      </button>
22    </div>
23  );
24}
25

Key points about useEffect:

  • Runs after every render by default
  • The dependency array controls when the effect runs
  • Returns an optional cleanup function
  • Replaces componentDidMount, componentDidUpdate, and componentWillUnmount

useContext: Consuming Context

The useContext hook provides a way to consume React context:

1import React, { useContext } from 'react';
2
3// Create a context
4const ThemeContext = React.createContext('light');
5
6function ThemedButton() {
7  // Consume the context
8  const theme = useContext(ThemeContext);
9  
10  return (
11    <button style={{ background: theme === 'dark' ? '#333' : '#fff', color: theme === 'dark' ? '#fff' : '#333' }}>
12      I am styled based on the theme context!
13    </button>
14  );
15}
16
17function App() {
18  return (
19    <ThemeContext.Provider value="dark">
20      <ThemedButton />
21    </ThemeContext.Provider>
22  );
23}
24

Key points about useContext:

  • Accepts a context object created by React.createContext
  • Returns the current context value
  • Always re-renders when the context value changes

Additional Hooks

useReducer: Complex State Logic

The useReducer hook is an alternative to useState for complex state logic:

1import React, { useReducer } from 'react';
2
3// Reducer function
4function counterReducer(state, action) {
5  switch (action.type) {
6    case 'increment':
7      return { count: state.count + 1 };
8    case 'decrement':
9      return { count: state.count - 1 };
10    case 'reset':
11      return { count: 0 };
12    default:
13      throw new Error(`Unsupported action type: ${action.type}`);
14  }
15}
16
17function Counter() {
18  // Initialize state with useReducer
19  const [state, dispatch] = useReducer(counterReducer, { count: 0 });
20  
21  return (
22    <div>
23      <p>Count: {state.count}</p>
24      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
25      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
26      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
27    </div>
28  );
29}
30

Key points about useReducer:

  • Takes a reducer function and initial state
  • Returns the current state and a dispatch function
  • Useful for complex state logic or when next state depends on previous state
  • Can optimize performance for components that trigger deep updates

useCallback: Memoized Callbacks

The useCallback hook returns a memoized callback function:

1import React, { useState, useCallback } from 'react';
2
3function ParentComponent() {
4  const [count, setCount] = useState(0);
5  
6  // This function is recreated only when count changes
7  const handleClick = useCallback(() => {
8    console.log(`Button clicked, count: ${count}`);
9  }, [count]);
10  
11  return (
12    <div>
13      <p>Count: {count}</p>
14      <button onClick={() => setCount(count + 1)}>Increment</button>
15      <ChildComponent onClick={handleClick} />
16    </div>
17  );
18}
19
20// Child component that uses React.memo to prevent unnecessary renders
21const ChildComponent = React.memo(({ onClick }) => {
22  console.log('ChildComponent rendered');
23  return <button onClick={onClick}>Click me</button>;
24});
25

Key points about useCallback:

  • Returns a memoized version of the callback
  • Only changes if one of the dependencies has changed
  • Useful when passing callbacks to optimized child components that rely on reference equality

useMemo: Memoized Values

The useMemo hook returns a memoized value:

1import React, { useState, useMemo } from 'react';
2
3function ExpensiveCalculation({ list }) {
4  const [filter, setFilter] = useState('');
5  
6  // This calculation is only performed when list or filter changes
7  const filteredList = useMemo(() => {
8    console.log('Filtering list...');
9    return list.filter(item => item.includes(filter));
10  }, [list, filter]);
11  
12  return (
13    <div>
14      <input
15        value={filter}
16        onChange={e => setFilter(e.target.value)}
17        placeholder="Filter items..."
18      />
19      <ul>
20        {filteredList.map((item, index) => (
21          <li key={index}>{item}</li>
22        ))}
23      </ul>
24    </div>
25  );
26}
27

Key points about useMemo:

  • Returns a memoized value
  • Only recomputes the value when dependencies change
  • Useful for expensive calculations or to maintain referential equality

useRef: Persistent Mutable Values

The useRef hook creates a mutable reference that persists across renders:

1import React, { useRef, useEffect } from 'react';
2
3function FocusInput() {
4  // Create a ref
5  const inputRef = useRef(null);
6  
7  // Focus the input on mount
8  useEffect(() => {
9    inputRef.current.focus();
10  }, []);
11  
12  return (
13    <div>
14      <input ref={inputRef} type="text" placeholder="I'll be focused on mount" />
15    </div>
16  );
17}
18

Key points about useRef:

  • Returns a mutable ref object with a .current property
  • The ref object persists for the full lifetime of the component
  • Changes to .current don't trigger re-renders
  • Useful for accessing DOM elements or storing mutable values

Creating Custom Hooks

One of the most powerful features of hooks is the ability to create custom hooks that encapsulate reusable logic:

Example: useLocalStorage

1import { useState, useEffect } from 'react';
2
3// Custom hook for persisting state to localStorage
4function useLocalStorage(key, initialValue) {
5  // Initialize state with value from localStorage or initialValue
6  const [storedValue, setStoredValue] = useState(() => {
7    try {
8      const item = window.localStorage.getItem(key);
9      return item ? JSON.parse(item) : initialValue;
10    } catch (error) {
11      console.error(error);
12      return initialValue;
13    }
14  });
15  
16  // Update localStorage when state changes
17  useEffect(() => {
18    try {
19      window.localStorage.setItem(key, JSON.stringify(storedValue));
20    } catch (error) {
21      console.error(error);
22    }
23  }, [key, storedValue]);
24  
25  return [storedValue, setStoredValue];
26}
27
28// Usage
29function App() {
30  const [name, setName] = useLocalStorage('name', 'Guest');
31  
32  return (
33    <div>
34      <input
35        value={name}
36        onChange={e => setName(e.target.value)}
37        placeholder="Enter your name"
38      />
39      <p>Hello, {name}!</p>
40    </div>
41  );
42}
43

Example: useFetch

1import { useState, useEffect } from 'react';
2
3// Custom hook for fetching data
4function useFetch(url) {
5  const [data, setData] = useState(null);
6  const [loading, setLoading] = useState(true);
7  const [error, setError] = useState(null);
8  
9  useEffect(() => {
10    let isMounted = true;
11    
12    async function fetchData() {
13      try {
14        setLoading(true);
15        const response = await fetch(url);
16        
17        if (!response.ok) {
18          throw new Error(`HTTP error! Status: ${response.status}`);
19        }
20        
21        const result = await response.json();
22        
23        if (isMounted) {
24          setData(result);
25          setError(null);
26        }
27      } catch (error) {
28        if (isMounted) {
29          setError(error.message);
30          setData(null);
31        }
32      } finally {
33        if (isMounted) {
34          setLoading(false);
35        }
36      }
37    }
38    
39    fetchData();
40    
41    return () => {
42      isMounted = false;
43    };
44  }, [url]);
45  
46  return { data, loading, error };
47}
48
49// Usage
50function UserProfile({ userId }) {
51  const { data, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
52  
53  if (loading) return <p>Loading...</p>;
54  if (error) return <p>Error: {error}</p>;
55  
56  return (
57    <div>
58      <h1>{data.name}</h1>
59      <p>Email: {data.email}</p>
60    </div>
61  );
62}
63

Best Practices and Common Pitfalls

Rules of Hooks

React Hooks come with two essential rules:

  1. Only call hooks at the top level

    • Don't call hooks inside loops, conditions, or nested functions
    • This ensures hooks are called in the same order each render
  2. Only call hooks from React functions

    • Call hooks from React functional components
    • Call hooks from custom hooks
1// ❌ Wrong: Hook inside a condition
2function MyComponent() {
3  const [count, setCount] = useState(0);
4  
5  if (count > 0) {
6    useEffect(() => {
7      document.title = `Count: ${count}`;
8    });
9  }
10  
11  return <button onClick={() => setCount(count + 1)}>Increment</button>;
12}
13
14// ✅ Correct: Condition inside the hook
15function MyComponent() {
16  const [count, setCount] = useState(0);
17  
18  useEffect(() => {
19    if (count > 0) {
20      document.title = `Count: ${count}`;
21    }
22  }, [count]);
23  
24  return <button onClick={() => setCount(count + 1)}>Increment</button>;
25}
26

Common Pitfalls

Stale Closures

1function Counter() {
2  const [count, setCount] = useState(0);
3  
4  // This will always alert 0!
5  const handleAlertClick = () => {
6    setTimeout(() => {
7      alert('You clicked on: ' + count);
8    }, 3000);
9  };
10  
11  return (
12    <div>
13      <p>You clicked {count} times</p>
14      <button onClick={() => setCount(count + 1)}>Click me</button>
15      <button onClick={handleAlertClick}>Show alert</button>
16    </div>
17  );
18}
19

Solution: Use a function update or useRef:

1function Counter() {
2  const [count, setCount] = useState(0);
3  const countRef = useRef(count);
4  
5  // Update ref when count changes
6  useEffect(() => {
7    countRef.current = count;
8  }, [count]);
9  
10  const handleAlertClick = () => {
11    setTimeout(() => {
12      alert('You clicked on: ' + countRef.current);
13    }, 3000);
14  };
15  
16  return (
17    <div>
18      <p>You clicked {count} times</p>
19      <button onClick={() => setCount(count + 1)}>Click me</button>
20      <button onClick={handleAlertClick}>Show alert</button>
21    </div>
22  );
23}
24

Missing Dependencies

1function SearchResults({ query }) {
2  const [results, setResults] = useState([]);
3  
4  // ❌ Missing dependency: query
5  useEffect(() => {
6    fetchResults(query).then(data => {
7      setResults(data);
8    });
9  }, []); // This effect runs only once, not when query changes
10  
11  return (
12    <ul>
13      {results.map(result => (
14        <li key={result.id}>{result.title}</li>
15      ))}
16    </ul>
17  );
18}
19

Solution: Add all dependencies:

1function SearchResults({ query }) {
2  const [results, setResults] = useState([]);
3  
4  // ✅ Correct: query is included in dependencies
5  useEffect(() => {
6    fetchResults(query).then(data => {
7      setResults(data);
8    });
9  }, [query]); // This effect runs when query changes
10  
11  return (
12    <ul>
13      {results.map(result => (
14        <li key={result.id}>{result.title}</li>
15      ))}
16    </ul>
17  );
18}
19

Advanced Patterns with Hooks

State Machines with useReducer

1import React, { useReducer } from 'react';
2
3// Define the state machine
4const initialState = { status: 'idle', data: null, error: null };
5
6function fetchReducer(state, action) {
7  switch (action.type) {
8    case 'FETCH_START':
9      return { status: 'loading', data: null, error: null };
10    case 'FETCH_SUCCESS':
11      return { status: 'success', data: action.payload, error: null };
12    case 'FETCH_ERROR':
13      return { status: 'error', data: null, error: action.payload };
14    case 'RESET':
15      return initialState;
16    default:
17      throw new Error(`Unsupported action type: ${action.type}`);
18  }
19}
20
21function DataFetcher({ url }) {
22  const [state, dispatch] = useReducer(fetchReducer, initialState);
23  
24  const fetchData = async () => {
25    dispatch({ type: 'FETCH_START' });
26    
27    try {
28      const response = await fetch(url);
29      
30      if (!response.ok) {
31        throw new Error(`HTTP error! Status: ${response.status}`);
32      }
33      
34      const data = await response.json();
35      dispatch({ type: 'FETCH_SUCCESS', payload: data });
36    } catch (error) {
37      dispatch({ type: 'FETCH_ERROR', payload: error.message });
38    }
39  };
40  
41  return (
42    <div>
43      <button onClick={fetchData} disabled={state.status === 'loading'}>
44        {state.status === 'loading' ? 'Loading...' : 'Fetch Data'}
45      </button>
46      
47      {state.status === 'success' && (
48        <div>
49          <h2>Data:</h2>
50          <pre>{JSON.stringify(state.data, null, 2)}</pre>
51        </div>
52      )}
53      
54      {state.status === 'error' && (
55        <div>
56          <h2>Error:</h2>
57          <p>{state.error}</p>
58        </div>
59      )}
60    </div>
61  );
62}
63

Compound Components with Context

1import React, { createContext, useContext, useState } from 'react';
2
3// Create context
4const TabsContext = createContext();
5
6// Tabs component
7function Tabs({ children, defaultIndex = 0 }) {
8  const [activeIndex, setActiveIndex] = useState(defaultIndex);
9  
10  return (
11    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
12      <div className="tabs">{children}</div>
13    </TabsContext.Provider>
14  );
15}
16
17// TabList component
18function TabList({ children }) {
19  return <div className="tab-list">{children}</div>;
20}
21
22// Tab component
23function Tab({ children, index }) {
24  const { activeIndex, setActiveIndex } = useContext(TabsContext);
25  const isActive = activeIndex === index;
26  
27  return (
28    <button
29      className={`tab ${isActive ? 'active' : ''}`}
30      onClick={() => setActiveIndex(index)}
31    >
32      {children}
33    </button>
34  );
35}
36
37// TabPanels component
38function TabPanels({ children }) {
39  return <div className="tab-panels">{children}</div>;
40}
41
42// TabPanel component
43function TabPanel({ children, index }) {
44  const { activeIndex } = useContext(TabsContext);
45  
46  if (activeIndex !== index) return null;
47  
48  return <div className="tab-panel">{children}</div>;
49}
50
51// Compose the components
52Tabs.TabList = TabList;
53Tabs.Tab = Tab;
54Tabs.TabPanels = TabPanels;
55Tabs.TabPanel = TabPanel;
56
57// Usage
58function App() {
59  return (
60    <Tabs>
61      <Tabs.TabList>
62        <Tabs.Tab index={0}>Tab 1</Tabs.Tab>
63        <Tabs.Tab index={1}>Tab 2</Tabs.Tab>
64        <Tabs.Tab index={2}>Tab 3</Tabs.Tab>
65      </Tabs.TabList>
66      
67      <Tabs.TabPanels>
68        <Tabs.TabPanel index={0}>Content for Tab 1</Tabs.TabPanel>
69        <Tabs.TabPanel index={1}>Content for Tab 2</Tabs.TabPanel>
70        <Tabs.TabPanel index={2}>Content for Tab 3</Tabs.TabPanel>
71      </Tabs.TabPanels>
72    </Tabs>
73  );
74}
75

Conclusion

React Hooks have transformed how we write React components, making it easier to reuse stateful logic, organize code by concern rather than lifecycle methods, and avoid the complexity of class components.

By mastering hooks, you can write more concise, maintainable, and reusable React code. Start by understanding the basic hooks, then explore creating custom hooks to encapsulate and share logic across your application.

As you continue your React journey, remember that hooks are just tools to help you build better components. The principles of good component design—such as single responsibility, encapsulation, and composition—still apply. Hooks simply make it easier to implement these principles in your React applications.