React Hooks Deep Dive: useState, useEffect, useCallback, useRef, and useMemo

I've shipped a lot of React code and made every hooks mistake in the book. Here's the practical guide I wish I had, five hooks, real examples, and the pitfalls that'll save you hours of debugging.

React Hooks Deep Dive: useState, useEffect, useCallback, useRef, and useMemo

I remember the first time I tried to refactor a class component to use hooks. I stared at useState for way too long, thinking “so this is just… state? That’s it?” And yeah, that’s basically it. But the subtleties of when and why to use each hook, that took me a lot longer to figure out.

Hooks aren’t hard individually. The tricky part is knowing which combination to reach for and when to stop optimizing. I’ve seen codebases where every single function is wrapped in useCallback “just in case,” and I’ve seen production apps with zero cleanup functions that leak event listeners everywhere. Both are mistakes I’ve made myself.

Here’s what I’ve learned about the five hooks that matter most.

useState: The One You’ll Use Constantly

State management in functional components. If you’ve written any modern React at all, you know this one.

import { useState } from 'react';

const [state, setState] = useState(initialValue);
function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('');
  const [todos, setTodos] = useState([]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      
      <input 
        value={name} 
        onChange={(e) => setName(e.target.value)} 
        placeholder="Enter your name"
      />
      
      <button onClick={() => setTodos([...todos, `Task ${todos.length + 1}`])}>
        Add Todo
      </button>
    </div>
  );
}

The Stale Closure Trap

This one bit me hard early on. If your new state depends on the previous state, always use the functional update form:

// Not recommended: can give you stale values
setCount(count + 1);

// Recommended: always gets the latest state
setCount(prevCount => prevCount + 1);

I had a counter in a polling component that would randomly skip numbers. Took me an hour to realize the closure was capturing a stale count value.

Split Your State

// Avoid combining unrelated state
const [user, setUser] = useState({ name: '', age: 0, email: '' });

// Better: separate state for independent values
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [email, setEmail] = useState('');

There’s a common instinct to dump everything into one object. Don’t. When values change independently, keep them separate, it’ll save you from unnecessary re-renders and make your code way easier to reason about.

Lazy Initialization

This one’s subtle but matters if you’re doing expensive work:

// Expensive computation runs on every render
const [value, setValue] = useState(expensiveComputation());

// Lazy initialization - computation runs only once
const [value, setValue] = useState(() => expensiveComputation());

Without the function form, that computation reruns every time the component re-renders, even though the result gets thrown away. I’ve seen this cause noticeable jank on complex forms.

useEffect: Where Things Get Confusing

useEffect is the hook everyone reaches for first and everyone struggles with. It handles side effects, data fetching, subscriptions, DOM manipulation, timers, all that stuff.

import { useEffect } from 'react';

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

The Dependency Array Is Everything

// Runs on every single render: almost never what you want
useEffect(() => {
  console.log('No dependencies');
});

// Runs once on mount
useEffect(() => {
  console.log('Empty dependency array');
}, []);

// Runs only when count changes
useEffect(() => {
  console.log('Count changed:', count);
}, [count]);

The number of bugs I’ve traced back to a wrong dependency array is… significant. Missing a dependency gives you stale data. Including too many gives you unnecessary re-fetches. There’s no trick to it, you just have to think about what your effect actually depends on.

Data Fetching

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

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (error) {
        console.error('Failed to fetch user:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

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

Quick note: for serious data fetching, you’ll eventually want TanStack Query or SWR instead of raw useEffect. The race condition handling alone is worth it. But for simple cases, this pattern works fine.

Event Listeners and Cleanup

function WindowTracker() {
  const [windowSize, setWindowSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });

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

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

  return <div>Window: {windowSize.width} x {windowSize.height}</div>;
}

I once shipped a component that added a scroll listener in useEffect without cleanup. It leaked memory on every navigation. The app got slower and slower until someone noticed the tab was using 2GB of RAM. Clean up your listeners. Every time.

Subscriptions

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    
    return () => connection.disconnect();
  }, [roomId]);

  return <ChatWindow />;
}

useCallback: Memoizing Functions

useCallback returns a memoized version of a function. It doesn’t run the function, it just ensures the same function reference is returned between renders, which matters when you’re passing callbacks to optimized children.

import { useCallback } from 'react';

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

When It Actually Matters

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

  // Without useCallback, this function is recreated on every render
  const handleClick = useCallback(() => {
    console.log('Count:', count);
  }, [count]);

  return (
    <>
      <MemoizedButton onClick={handleClick} />
      <button onClick={() => setOther(other + 1)}>Other: {other}</button>
    </>
  );
}

If MemoizedButton is wrapped in React.memo, it’ll re-render every time the parent renders, unless the callback reference stays stable. That’s what useCallback gives you.

It also matters when you’re using a callback inside useEffect:

function SearchComponent({ searchQuery }) {
  const handleSearch = useCallback((query) => {
    console.log('Searching:', query);
  }, []);

  useEffect(() => {
    handleSearch(searchQuery);
  }, [handleSearch, searchQuery]);

  return <SearchInput onSearch={handleSearch} />;
}

The Overuse Trap

Here’s the thing nobody tells you: most components don’t need useCallback. If the child isn’t memoized, or if the function isn’t a dependency of some effect, wrapping it in useCallback is just adding overhead for zero benefit.

// Don't wrap everything in useCallback
const handleClick = useCallback(() => {
  doSomething();
}, []);

// This is fine in most cases
const handleClick = () => {
  doSomething();
};

Profile first. Optimize second.

useRef: The Swiss Army Knife

useRef serves two purposes: accessing DOM elements directly, and storing mutable values that persist across renders without triggering re-renders.

import { useRef } from 'react';

const refContainer = useRef(initialValue);

Focusing Inputs

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

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

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

Storing Intervals and Timers

This is the use case I reach for useRef for most often:

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

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);

    return () => clearInterval(intervalRef.current);
  }, []);

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

  return (
    <div>
      <p>Timer: {count}s</p>
      <button onClick={stopTimer}>Stop</button>
    </div>
  );
}

If you stored the interval ID in useState, stopping it would trigger a re-render. useRef keeps it around without the render overhead.

Tracking Previous Values

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

  useEffect(() => {
    prevCountRef.current = count;
  }, [count]);

  const prevCount = prevCountRef.current;

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

I use this pattern more than I expected to. Comparing previous and current values is surprisingly common in UI logic.

useMemo: Caching Expensive Work

useMemo memoizes the result of a computation. If the dependencies haven’t changed, it returns the cached result instead of recalculating.

import { useMemo } from 'react';

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

When It’s Worth It

function Fibonacci({ n }) {
  const fib = useMemo(() => {
    const calculateFib = (num) => {
      if (num <= 1) return num;
      return calculateFib(num - 1) + calculateFib(num - 2);
    };
    return calculateFib(n);
  }, [n]);

  return <div>Fibonacci({n}) = {fib}</div>;
}

Filtering and sorting large lists is another good candidate:

function ProductList({ products, filter, sortBy }) {
  const filteredAndSortedProducts = useMemo(() => {
    let result = [...products];
    
    if (filter) {
      result = result.filter(p => p.category === filter);
    }
    
    if (sortBy) {
      result.sort((a, b) => a[sortBy] - b[sortBy]);
    }
    
    return result;
  }, [products, filter, sortBy]);

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

Object Reference Stability

This one’s less obvious but useful:

function UserSettings({ userId }) {
  const defaultSettings = useMemo(() => ({
    theme: 'dark',
    notifications: true,
    language: 'en'
  }), []);

  const [settings, setSettings] = useState(defaultSettings);

  // defaultSettings won't change on re-renders
  return <SettingsForm settings={settings} />;
}

Without useMemo, that object gets recreated every render, which could trigger unnecessary re-renders in child components.

Putting It All Together

Here’s a dashboard that uses all five hooks:

function Dashboard({ userId }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [filter, setFilter] = useState('all');
  const searchInputRef = useRef(null);
  const renderCount = useRef(0);

  // Track render count (debugging)
  renderCount.current += 1;

  // Fetch data when userId changes
  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      try {
        const response = await fetch(`/api/users/${userId}/data`);
        const result = await response.json();
        setData(result);
      } catch (error) {
        console.error('Fetch failed:', error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [userId]);

  // Memoize filtered data
  const filteredData = useMemo(() => {
    if (!data) return [];
    
    return data.items.filter(item => {
      if (filter === 'all') return true;
      return item.category === filter;
    });
  }, [data, filter]);

  // Memoize filter handler
  const handleFilterChange = useCallback((newFilter) => {
    setFilter(newFilter);
  }, []);

  // Focus search input
  const focusSearch = () => {
    searchInputRef.current?.focus();
  };

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <input ref={searchInputRef} placeholder="Search..." />
      <button onClick={focusSearch}>Focus Search</button>
      
      <FilterSelector value={filter} onChange={handleFilterChange} />
      
      <DataList items={filteredData} />
      
      <small>Render count: {renderCount.current}</small>
    </div>
  );
}

Quick Reference

HookPurposeRe-renders Component?Use When
useStateState managementYesYou need reactive state
useEffectSide effectsNo (but can trigger state updates)Data fetching, subscriptions, DOM manipulation
useCallbackMemoize functionsNoPassing stable callbacks to optimized children
useRefDOM access / mutable valuesNoDirect DOM access, storing mutable values
useMemoMemoize valuesNoExpensive computations, stable object references

The Honest Performance Advice

Don’t optimize prematurely. I’ve seen developers wrap everything in useMemo and useCallback “just to be safe,” and the result is code that’s harder to read with zero measurable performance gain.

Only reach for useMemo and useCallback when you have an actual performance issue or genuinely need reference stability (like for memoized children or effect dependencies). Use the React DevTools Profiler to find real bottlenecks. Keep your dependency arrays accurate, missing dependencies cause bugs, extra ones cause unnecessary work.

Install eslint-plugin-react-hooks. It’ll catch most of the common mistakes before they reach production.

The best optimization is usually not the one you think it is. Sometimes it’s just splitting a component into smaller pieces so React can skip rendering the parts that didn’t change.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.