React Hooks Deep Dive: Beyond useState and useEffect

Most React developers know useState and useEffect. But React offers several other hooks that can make your code cleaner, faster, and more maintainable. Let’s explore the hooks you might be missing.

useCallback: Memoizing Functions

Every time a component re-renders, all functions inside it are recreated. Usually this is fine, but it becomes a problem when you pass functions to child components or use them in dependency arrays.

The Problem

function Parent() {
  const [count, setCount] = useState(0);
  
  // This function is recreated on every render
  const handleClick = () => {
    console.log('clicked');
  };
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChild onClick={handleClick} />
    </>
  );
}

Every time count changes, handleClick is a new function, causing ExpensiveChild to re-render even though the function does the same thing.

The Solution

function Parent() {
  const [count, setCount] = useState(0);
  
  // This function is only created once
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);
  
  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
      <ExpensiveChild onClick={handleClick} />
    </>
  );
}

When Dependencies Matter

If your callback needs access to state or props, include them in the dependency array:

function SearchForm({ onSearch }) {
  const [query, setQuery] = useState('');
  
  const handleSubmit = useCallback(() => {
    onSearch(query);
  }, [query, onSearch]); // Recreated when query or onSearch changes
  
  return (
    <form onSubmit={handleSubmit}>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <button type="submit">Search</button>
    </form>
  );
}

useMemo: Memoizing Values

While useCallback memoizes functions, useMemo memoizes the result of a computation.

The Problem

function ProductList({ products, filter }) {
  // This runs on EVERY render, even if products and filter haven't changed
  const filteredProducts = products.filter(p => 
    p.category === filter && p.inStock
  );
  
  return (
    <ul>
      {filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

The Solution

function ProductList({ products, filter }) {
  // Only recalculates when products or filter change
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === filter && p.inStock);
  }, [products, filter]);
  
  return (
    <ul>
      {filteredProducts.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}

Real-World Example: Sorting and Filtering

function DataTable({ data, sortKey, sortDirection, searchTerm }) {
  const processedData = useMemo(() => {
    // Filter first
    let result = data.filter(item =>
      Object.values(item).some(val =>
        String(val).toLowerCase().includes(searchTerm.toLowerCase())
      )
    );
    
    // Then sort
    result.sort((a, b) => {
      const aVal = a[sortKey];
      const bVal = b[sortKey];
      const modifier = sortDirection === 'asc' ? 1 : -1;
      
      if (aVal < bVal) return -1 * modifier;
      if (aVal > bVal) return 1 * modifier;
      return 0;
    });
    
    return result;
  }, [data, sortKey, sortDirection, searchTerm]);
  
  return <Table data={processedData} />;
}

useRef: Beyond DOM References

Most people know useRef for accessing DOM elements, but it’s also perfect for storing mutable values that don’t trigger re-renders.

Accessing DOM Elements

function TextInput() {
  const inputRef = useRef(null);
  
  const focusInput = () => {
    inputRef.current.focus();
  };
  
  return (
    <>
      <input ref={inputRef} />
      <button onClick={focusInput}>Focus</button>
    </>
  );
}

Storing Previous Values

function Counter() {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef();
  
  useEffect(() => {
    prevCountRef.current = count;
  });
  
  const prevCount = prevCountRef.current;
  
  return (
    <div>
      <p>Current: {count}, Previous: {prevCount}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

Storing Interval IDs

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const intervalRef = useRef(null);
  
  const start = () => {
    if (intervalRef.current) return; // Already running
    
    intervalRef.current = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
  };
  
  const stop = () => {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
  };
  
  useEffect(() => {
    return () => clearInterval(intervalRef.current);
  }, []);
  
  return (
    <div>
      <p>Seconds: {seconds}</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
    </div>
  );
}

useReducer: Complex State Logic

When state logic gets complex, useReducer can be cleaner than multiple useState calls.

The useState Approach

function ShoppingCart() {
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [discount, setDiscount] = useState(0);
  const [isLoading, setIsLoading] = useState(false);
  
  const addItem = (item) => {
    setItems([...items, item]);
    setTotal(total + item.price);
  };
  
  const removeItem = (id) => {
    const item = items.find(i => i.id === id);
    setItems(items.filter(i => i.id !== id));
    setTotal(total - item.price);
  };
  
  // ... more functions updating multiple state values
}

The useReducer Approach

const initialState = {
  items: [],
  total: 0,
  discount: 0,
  isLoading: false,
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price,
      };
    
    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload);
      return {
        ...state,
        items: state.items.filter(i => i.id !== action.payload),
        total: state.total - item.price,
      };
    }
    
    case 'APPLY_DISCOUNT':
      return {
        ...state,
        discount: action.payload,
        total: state.total * (1 - action.payload / 100),
      };
    
    case 'SET_LOADING':
      return { ...state, isLoading: action.payload };
    
    default:
      return state;
  }
}

function ShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, initialState);
  
  const addItem = (item) => dispatch({ type: 'ADD_ITEM', payload: item });
  const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: id });
  const applyDiscount = (percent) => dispatch({ type: 'APPLY_DISCOUNT', payload: percent });
  
  return (
    // JSX using state.items, state.total, etc.
  );
}

Benefits of useReducer

  1. Single source of truth: All state updates in one place
  2. Predictable: Given the same state and action, you always get the same result
  3. Testable: Reducers are pure functions, easy to unit test
  4. Debuggable: You can log every action to see state changes

Building Custom Hooks

Combine these hooks to create reusable logic:

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

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

  return [storedValue, setValue];
}

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

When to Optimize

A word of caution: don’t optimize prematurely. These hooks add complexity. Use them when:

  • useCallback: Passing callbacks to memoized child components, or using callbacks in dependency arrays
  • useMemo: Computing derived data from large datasets, or creating objects used in dependency arrays
  • useReducer: State updates depend on multiple values, or you have complex state transitions

For simple cases, the overhead of memoization might be worse than just letting React re-run the code.

Final Thoughts

These hooks are tools, not rules. Start simple with useState and useEffect. When you notice performance issues or complex state logic, reach for these advanced hooks.

The key insight is understanding what triggers re-renders and when that matters. Most of the time, React is fast enough without optimization. When it’s not, these hooks give you precise control over what gets recalculated and when.