Back to blog

The hooks you should use more

useRef, useMemo, and useCallback aren't optimization hooks. They're correctness hooks that happen to also improve performance.

January 23, 202612 min read
ReactHooksPerformanceuseRefuseMemouseCallback
The hooks you should use more
The Memory Trio - useRef, useMemo, useCallback
The Memory Trio - useRef, useMemo, useCallback

You know useState and useEffect. They're your daily drivers.

But there's a second tier of hooks that most devs treat like "advanced stuff" or "optimization tricks." They avoid them until they absolutely have to use them.

That's backwards.

useRef, useMemo, and useCallback aren't optimization hooks. They're correctness hooks that happen to also improve performance. And once you understand them, you'll wonder how you ever wrote React without them.

Let's fix that.


useRef: the escape hatch nobody uses enough

Most devs think useRef is for "accessing DOM elements." That's true, but it's only half the story.

useRef is a mutable container that survives re-renders without triggering them.

Read that again. It's the key to everything.

The DOM use case (the one you know)

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

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>Focus</button>
    </>
  );
}

You attach a ref to a DOM element, and inputRef.current gives you direct access to it. Standard stuff.

The use case nobody talks about

Here's where it gets interesting. Need to store a value that:

  • Persists across renders
  • Doesn't trigger a re-render when it changes
  • Can be read synchronously (unlike state)

That's useRef.

function Stopwatch() {
  const [time, setTime] = useState(0);
  const intervalRef = useRef(null);

  function start() {
    intervalRef.current = setInterval(() => {
      setTime(t => t + 1);
    }, 1000);
  }

  function stop() {
    clearInterval(intervalRef.current);
  }

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

Why not store intervalId in state? Because changing it would trigger a re-render. And you don't want to re-render just because you stored a timer ID.

The "previous value" pattern

Ever needed to compare current props with previous props?

function useWhyDidYouUpdate(name, props) {
  const previousProps = useRef(props);

  useEffect(() => {
    const changes = {};
    Object.keys(props).forEach(key => {
      if (previousProps.current[key] !== props[key]) {
        changes[key] = { from: previousProps.current[key], to: props[key] };
      }
    });
    
    if (Object.keys(changes).length > 0) {
      console.log('[why-did-you-update]', name, changes);
    }
    
    previousProps.current = props;
  });
}

This hook tells you exactly which props caused a re-render. Invaluable for debugging.

The mental model

Think of useRef as a box sitting next to your component.

The box doesn't care about renders. It just holds whatever you put in it.


useMemo: stop recalculating what hasn't changed

Here's a component that filters a huge list:

function UserList({ users, filter }) {
  const filteredUsers = users.filter(user => 
    user.name.toLowerCase().includes(filter.toLowerCase())
  );

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

Every render, you filter the entire list. Even if users and filter haven't changed.

With 10 users? Who cares. With 10,000 users? Your UI stutters.

The fix

function UserList({ users, filter }) {
  const filteredUsers = useMemo(() => {
    return users.filter(user => 
      user.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [users, filter]);

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

Now the filtering only runs when users or filter actually change. Any other re-render (parent state change, context update, etc.) skips the expensive work.

The real reason useMemo exists

Performance is nice. But there's a deeper reason: referential stability.

function Parent() {
  const [count, setCount] = useState(0);
  
  // New object every render
  const config = { theme: 'dark', size: 'large' };
  
  return <Child config={config} />;
}

const Child = memo(({ config }) => {
  console.log('Child rendered');
  return <div>{config.theme}</div>;
});

Child is wrapped in memo, but it still re-renders every time Parent renders. Why? Because config is a new object every time. Same values, different reference.

function Parent() {
  const [count, setCount] = useState(0);
  
  // Same object reference unless values change
  const config = useMemo(() => ({ theme: 'dark', size: 'large' }), []);
  
  return <Child config={config} />;
}

Now Child only re-renders when the config actually changes.

When NOT to use useMemo

Don't wrap everything in useMemo. It has overhead. Use it when:

  1. The calculation is actually expensive (measure first!)
  2. You're passing objects/arrays to memoized children
  3. The value is used as a dependency in other hooks
// ❌ Overkill
const doubled = useMemo(() => count * 2, [count]);

// ✅ Just compute it
const doubled = count * 2;

The mental model

Think of useMemo as a cache with automatic invalidation.


useCallback: useMemo for functions

Here's the thing: useCallback is literally just useMemo for functions.

// These are equivalent
const handleClick = useCallback(() => {
  console.log('clicked');
}, []);

const handleClick = useMemo(() => {
  return () => console.log('clicked');
}, []);

So why does useCallback exist? Convenience. Functions are so commonly passed as props that they got their own hook.

The problem it solves

function SearchPage() {
  const [query, setQuery] = useState('');
  
  function handleSearch(term) {
    // fetch results...
  }
  
  return <SearchInput onSearch={handleSearch} />;
}

const SearchInput = memo(({ onSearch }) => {
  console.log('SearchInput rendered');
  return <input onChange={e => onSearch(e.target.value)} />;
});

SearchInput is memoized, but it re-renders every time SearchPage renders. Because handleSearch is a new function every render.

function SearchPage() {
  const [query, setQuery] = useState('');
  
  const handleSearch = useCallback((term) => {
    // fetch results...
  }, []);
  
  return <SearchInput onSearch={handleSearch} />;
}

Now handleSearch keeps the same reference. SearchInput stays memoized.

The useEffect dependency trap

This is where useCallback really shines:

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  function createConnection() {
    return connectToRoom(roomId);
  }
  
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createConnection]); // 🔴 New function every render = infinite loop!
}

The effect depends on createConnection, which is new every render. So the effect runs every render. Infinite connection/disconnection loop.

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  
  const createConnection = useCallback(() => {
    return connectToRoom(roomId);
  }, [roomId]);
  
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createConnection]); // ✅ Only changes when roomId changes
}

Or even better, move the function inside the effect:

useEffect(() => {
  function createConnection() {
    return connectToRoom(roomId);
  }
  
  const connection = createConnection();
  connection.connect();
  return () => connection.disconnect();
}, [roomId]); // ✅ Clean and simple

The state updater trick

Need to update state inside a callback but don't want the callback to depend on state?

// ❌ handleAdd changes every time todos changes
const handleAdd = useCallback((text) => {
  setTodos([...todos, { id: Date.now(), text }]);
}, [todos]);

// ✅ handleAdd never changes
const handleAdd = useCallback((text) => {
  setTodos(prev => [...prev, { id: Date.now(), text }]);
}, []);

The functional update pattern lets you remove todos from dependencies entirely.

The mental model


The cheat sheet

HookWhat it cachesUse when
useRefMutable value (no re-render)DOM access, timers, previous values
useMemoComputed valueExpensive calculations, stable references
useCallbackFunction referencePassing callbacks to memoized children, effect dependencies

The trio in action

Here's all three working together:

function ProductList({ products, onSelect }) {
  const searchRef = useRef(null);
  const [filter, setFilter] = useState('');
  
  // Memoize expensive filtering
  const filteredProducts = useMemo(() => {
    return products.filter(p => 
      p.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [products, filter]);
  
  // Stable callback for child components
  const handleSelect = useCallback((product) => {
    onSelect(product);
    searchRef.current?.focus();
  }, [onSelect]);
  
  return (
    <div>
      <input 
        ref={searchRef}
        value={filter}
        onChange={e => setFilter(e.target.value)}
        placeholder="Search..."
      />
      {filteredProducts.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onSelect={handleSelect}
        />
      ))}
    </div>
  );
}

const ProductCard = memo(({ product, onSelect }) => {
  return (
    <div onClick={() => onSelect(product)}>
      {product.name}
    </div>
  );
});
  • useRef for DOM access without re-renders
  • useMemo for expensive filtering
  • useCallback for stable function reference
  • memo to complete the optimization chain

What's next?

You now have the core hooks that handle 95% of React development.

But there's a third tier: the hooks that unlock entirely new patterns. useReducer for complex state machines. useLayoutEffect for DOM measurements. useImperativeHandle for exposing custom APIs.

In the next article, we'll explore these advanced patterns and when to reach for them.

Continue to part 3: Hooks that unlock new patterns →


Got questions? Found a bug in my code? Hit me up on LinkedIn or check out more on my blog.