ESC

Type to search the knowledge base.

useWhyDidYouUpdate Hook

Diff previous props in a ref and log only the keys that actually changed. A debug hook, not a production profiler, and Object.is is the compare.

intermediate7 min read
  • react
  • hooks
  • debugging
  • interview

A memoized row still re-renders and you do not know why. You log the whole props object. Everything looks the same. The actual bug is style={{ margin: 0 }} or onSelect={() => ...}: new identity, same fields. Guessing with console.log does not name the key.

useWhyDidYouUpdate stores the previous props in a ref, compares each key with Object.is after commit, and logs only the keys that changed. It is a debug aid. It is not the React DevTools Profiler. It does not measure commit time, it does not rank components, and console.log on a hot path is itself a slowdown. Leave it out of production bundles.

The compare is Object.is because that is what React uses for memo and state bailout. A deep equal would hide the identity bug you came to find. The ref write waits until after paint, same timing as usePrevious: a discarded concurrent render must not move “previous.”

  • Keep last committed props in a ref. Compare after paint. Log only keys that fail Object.is.
  • Seed the ref with the first props object so mount does not print every key as “from undefined.”
  • Object.is is SameValue: NaN matches NaN, +0 does not match -0. It is not deep equality.
  • A new function or object on a key is a change. That is the answer, not a reason to deep-diff.
  • Always call the hook. Branch on NODE_ENV inside. Do not ship this as a profiler.

Diff prev props in a ref, log only what actually changed

import { useEffect, useRef } from 'react';

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

  useEffect(() => {
    const prev = prevRef.current;
    const keys = new Set([...Object.keys(prev), ...Object.keys(props)]);
    const changed = {};

    keys.forEach((key) => {
      const from = prev[key];
      const to = props[key];
      if (!Object.is(from, to)) {
        changed[key] = { from, to };
      }
    });

    if (Object.keys(changed).length > 0) {
      console.log('[why-did-you-update]', name, changed);
    }

    prevRef.current = props;
  });
}
function Row({ item, onSelect, style }) {
  useWhyDidYouUpdate('Row', { item, onSelect, style });
  return (
    <button type="button" style={style} onClick={() => onSelect(item.id)}>
      {item.label}
    </button>
  );
}

Walk one update:

  1. Mount. prevRef is initialized to this props. The effect runs, Object.is on each key is true (same object as the seed), changed is empty, no log. The effect writes props again (same reference).
  2. Parent re-renders with a new onSelect arrow and the same item. The effect diffs. onSelect fails Object.is. You log { onSelect: { from: ƒ, to: ƒ } }. item is silent if it is the same reference.
  3. Parent re-renders with a cloned item ({ ...item }). item logs even when id and label match. The hook is working. Your parent is allocating.

The omitted dependency array is intentional: you want every commit, not only when a props identity changes. props is a new object every time the child runs if you pass an object literal into the hook (useWhyDidYouUpdate('Row', { item, onSelect })). That is expected. You are diffing keys, not the bag.

If you pass [props], the effect skips when the parent reuses the same props object. Fine for a frozen props identity. Wrong for the usual “inline object in JSX” call. Leave the array off, like usePrevious without [value].

Why Object.is, and why not deep equal

React’s bailout and memo use Object.is per prop. Your debug hook should explain React’s decision, not a different equality.

Pair === Object.is What you want in this hook
NaN, NaN false true No log. React did not see a change.
+0, -0 true false Log. Rare, but it is a real SameValue difference.
Two { id: 1 } literals false false Log. Identity changed.
Same function reference true true No log.

Object.is vs === is the language note. Shallow vs deep equality is the trap: if you deep-compare style and skip the log, you will never notice that style={{ margin: 0 }} defeats memo. The log is supposed to be loud.

Keys that appear only on one side count as a change (from or to is undefined). Object.keys does not include symbols or inherited enumerable keys. Props are a plain object with string keys. That is enough. You do not walk the prototype.

Timing: write after commit

The tempting render-time version:

function useWhyDidYouUpdateBroken(name, props) {
  const prevRef = useRef(props);
  const prev = prevRef.current;
  // side effect during render
  prevRef.current = props;
  // compare prev vs props, log...
}

It looks right in a synchronous demo. Concurrent React can render, throw away, render again. The discarded render already moved the ref, so the surviving render diffs against the wrong previous. Effects do not run for discarded renders. Keep the write in useEffect, same rule as usePrevious.

useLayoutEffect also runs after commit. You do not need layout timing to print a diff. useEffect is enough and will not delay paint.

Dev-only, still unconditional

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

  useEffect(() => {
    if (process.env.NODE_ENV === 'production') return undefined;

    const prev = prevRef.current;
    // ...diff and log...
    prevRef.current = props;
  });
}

Do not write if (dev) useWhyDidYouUpdate(...). That changes hook order between builds and between the parent’s own conditionals. Rules of Hooks: call it every render, no-op inside.

Tree-shaking the hook out of production is a bundler job (import.meta.env.DEV in Vite, process.env.NODE_ENV dead-code). A no-op effect in production is still an effect. Prefer a wrapping file that exports an empty function in prod if you care. Interviews want the “always call, branch inside” sentence more than the bundler config.

This hook does not replace the Profiler. The Profiler records commits, ranks components, and shows why this rendered (parent render, hook change, props). useWhyDidYouUpdate only names prop keys. State and context changes that re-render a child with identical props produce an empty diff. That empty log is data: the cause is not props. Look at the parent, context, or an internal useState.

Footguns

Logging on mount. If you seed useRef({}) instead of useRef(props), the first effect treats every key as added. Seed with props.

Deep cloning previous props. You do not need a copy. You need the last reference long enough to compare. Cloning hides identity changes and allocates on every commit.

Putting the result on screen. The hook returns undefined. If the UI must show “prop X changed,” that is state, and you just built a render loop magnet. Keep it in the console.

Comparing children. children is often a new element tree (type, props, key) every parent render. It will always log. Diff the props you actually memo on.

Using it to “prove” memo is broken without reading the log. If onClick is in the changed map, memo is doing its job. Stabilize the callback (useMemo and useCallback) or stop passing it.

Interview out-loud answer

I keep the previous props object in a ref, seeded with the first props so mount is quiet. After each commit an effect walks the union of keys and records entries where Object.is fails. I log only that map. I do not write the ref during render, because discarded concurrent renders would corrupt “previous.” I use Object.is on purpose: it matches memo, and a deep equal would hide new object identities. This is a debug hook. For timings and commit ranks I would open the DevTools Profiler, not ship console.log in production. The hook always runs; I no-op inside production.

Further reading

Related guides