People frequently confuse “memoizing values inside a hook” with “memoizing the hook’s return value.” These sound like the same idea, but they solve different problems. The first stops expensive calculations from re-running. The second stops every component that calls your hook from re-rendering just because the hook returned a new object reference. A hook can get one right and still fail at the other, which is exactly why so many “optimized” custom hooks still cause performance issues downstream.

Custom hooks are just functions, so it’s tempting to write them the same way you’d write any other utility function. That habit is where most of the performance problems start. Below is a side-by-side look at how beginner-level hook code typically handles a handful of common situations, compared with the patterns that hold up better once a hook is used in more than one place.


Returning an Object or Array: Beginner vs. Advanced

Beginner pattern: Return a plain object literal from the hook, built fresh on every call.

function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return { width: size.width, height: size.height, ready: true };
}

This looks harmless. The problem shows up in the consuming component: { width: size.width, height: size.height, ready: true } creates a new object on every render of the hook, even when width and height haven’t changed. If a memoized child component receives this object as a prop, React.memo on that child does nothing, because the reference changes every time.

Advanced pattern: Memoize the returned object so its reference stays stable across renders where the underlying values haven’t changed.

function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return useMemo(
    () => ({ width: size.width, height: size.height, ready: true }),
    [size.width, size.height]
  );
}

Now the returned object only changes reference when width or height actually differ from the previous render. This one change is often what makes downstream React.memo calls start working at all.


Callbacks Returned From a Hook: Beginner vs. Advanced

Beginner pattern: Define a plain function inside the hook body and return it directly.

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue(v => !v);
  return [value, toggle];
}

This works fine functionally, but toggle is a new function reference on every render. If a component passes toggle down to a memoized child, or uses it as a dependency in another hook’s dependency array, that instability propagates outward. The bug won’t show up as broken behavior — it shows up as re-renders that shouldn’t be happening, which is much harder to trace.

Advanced pattern: Wrap the returned function in useCallback with an empty (or minimal) dependency array.

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(v => !v), []);
  return [value, toggle];
}

Because the setter function passed to setValue uses the updater form (v => !v), toggle never needs value in its dependency array. That’s a small detail, but it’s the difference between a stable callback and one that silently changes reference every time value updates.


Expensive Calculations Inside a Hook: Beginner vs. Advanced

Beginner pattern: Run a costly computation directly in the hook body on every call.

function useFilteredList(items, query) {
  const filtered = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );
  return filtered;
}

For a list of a few dozen items, this is invisible. For a list of several thousand, called on every keystroke as a parent re-renders for unrelated reasons, this recalculates the full filter pass each time regardless of whether items or query changed.

Advanced pattern: Wrap the computation in useMemo with the actual inputs it depends on.

function useFilteredList(items, query) {
  return useMemo(() => {
    const normalizedQuery = query.toLowerCase();
    return items.filter(item => item.name.toLowerCase().includes(normalizedQuery));
  }, [items, query]);
}

Note the dependency on items itself, not just query. If items is recreated on every parent render (a common problem, discussed below), this memoization provides no benefit at all — which is a good reminder that fixing one hook in isolation isn’t always enough.


Dependency Arrays: Beginner vs. Advanced

Beginner pattern: Pass an object or array prop directly into a dependency array without thinking about where it came from.

function useSortedData(data, options) {
  return useMemo(() => sortData(data, options), [data, options]);
}

If the calling component does useSortedData(rawData, { key: "name" }), that inline object literal is a new reference on every render of the parent. The useMemo inside useSortedData recalculates every time, even though options.key never changes. This is the same reference-instability problem seen with React.memo, just one layer removed and easier to miss because the hook itself looks correctly written.

Advanced pattern: Depend on primitive values extracted from the object, rather than the object reference itself.

function useSortedData(data, options) {
  const { key, direction = "asc" } = options;
  return useMemo(() => sortData(data, { key, direction }), [data, key, direction]);
}

By destructuring down to primitives, the dependency array now tracks the values that matter instead of an object reference that changes on every parent render. This fix requires no change at all on the calling side — it’s entirely contained inside the hook, which makes it a good default habit for any hook accepting an options object.


One Large Hook vs. Several Small Hooks

Beginner pattern: Build a single hook that manages several unrelated pieces of state and logic, because it’s convenient to have “one hook that does everything for this feature.”

function useDashboardData(userId) {
  const [profile, setProfile] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [settings, setSettings] = useState(null);

  useEffect(() => { fetchProfile(userId).then(setProfile); }, [userId]);
  useEffect(() => { fetchNotifications(userId).then(setNotifications); }, [userId]);
  useEffect(() => { fetchSettings(userId).then(setSettings); }, [userId]);

  return { profile, notifications, settings };
}

Every state update inside this hook — a new notification arriving, a settings change — triggers a re-render of every component that calls useDashboardData, even the ones only reading profile. The hook has coupled three unrelated pieces of state into one render cycle.

Advanced pattern: Split the hook so components only subscribe to the state they actually consume.

function useProfile(userId) {
  const [profile, setProfile] = useState(null);
  useEffect(() => { fetchProfile(userId).then(setProfile); }, [userId]);
  return profile;
}

function useNotifications(userId) {
  const [notifications, setNotifications] = useState([]);
  useEffect(() => { fetchNotifications(userId).then(setNotifications); }, [userId]);
  return notifications;
}

A component that only needs profile now calls useProfile(userId) and stops re-rendering when notifications change. This is less convenient to write than one combined hook, but it removes an entire category of unnecessary re-renders that no amount of React.memo further down the tree can fix, because the state update is happening above the memo boundary.


Side-by-Side Summary

Situation Beginner Pattern Advanced Pattern
Returning an object/array New literal every render Wrapped in useMemo with real dependencies
Returning a function Plain function, new reference each render Wrapped in useCallback with a minimal dependency array
Expensive computation Recomputed on every call Wrapped in useMemo, dependent on stable inputs
Dependency arrays Objects/arrays passed in directly Destructured to primitives before use in dependencies
Hook scope One large hook covering multiple concerns Split into focused hooks with narrow subscriptions

Checking Whether Any of This Actually Matters for Your Hook

Not every custom hook needs this treatment. A hook called once near the top of a small app, feeding a component with no memoized children, gains nothing from useMemo and useCallback — the comparison overhead can outweigh the savings, the same way it can with React.memo on a cheap component. The pattern worth adopting is: profile first with React DevTools, confirm that a hook’s unstable references are causing measurable re-renders somewhere downstream, and only then apply memoization inside the hook itself.

If you have a custom hook you suspect is causing re-render problems, describe what it returns and where it’s consumed — that’s usually enough to tell whether the fix belongs inside the hook or in how it’s being called.