DEV Community

Tihomir Ivanov
Tihomir Ivanov

Posted on

Referential Equality & Memoization: Why `{} !== {}` Breaks React Performance

You've probably seen code like this in React:

<ChildComponent config={{ theme: 'dark' }} />
Enter fullscreen mode Exit fullscreen mode

And wondered why ChildComponent re-renders on every parent render, even when wrapped in React.memo.

Or you've used useEffect and seen it run repeatedly:

useEffect(() => {
  fetchData();
}, [config]); // 'config' is recreated every render!
Enter fullscreen mode Exit fullscreen mode

The culprit? Referential equality — JavaScript's way of comparing objects and arrays by reference, not by value.

Understanding this is critical for writing performant React applications.

The Golden Rule

In JavaScript, objects and arrays are compared by reference (memory address), not by value. Two objects with identical contents are NOT equal if they're different instances. This means {} !== {} and [] !== []. React uses referential equality to determine when to re-render components, so creating new objects/arrays on every render can cause unnecessary re-renders.

In simpler terms: JavaScript doesn't care if two objects look the same — it only cares if they're the SAME object in memory.

Let's understand why this matters and how memoization solves it.


Part 1: Referential Equality Explained

Primitives vs Objects

Primitives (numbers, strings, booleans, null, undefined) are compared by value:

const a = 5;
const b = 5;
console.log(a === b); // true

const str1 = 'hello';
const str2 = 'hello';
console.log(str1 === str2); // true
Enter fullscreen mode Exit fullscreen mode

Objects (including arrays, functions) are compared by reference:

const obj1 = { name: 'Alice' };
const obj2 = { name: 'Alice' };
console.log(obj1 === obj2); // false (different references!)

const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
console.log(arr1 === arr2); // false
Enter fullscreen mode Exit fullscreen mode

Why? obj1 and obj2 are stored at different memory addresses, even though they have identical contents.


What About Same Reference?

const obj1 = { name: 'Alice' };
const obj2 = obj1; // Same reference

console.log(obj1 === obj2); // true (same memory address)

obj2.name = 'Bob';
console.log(obj1.name); // "Bob" (mutation affects both!)
Enter fullscreen mode Exit fullscreen mode

Key Point: When you assign an object to another variable, you're copying the reference, not the object itself. (See the "Pass by Value" article for more on this!)


Part 2: How This Affects React

React uses referential equality to determine if props have changed:

Example: React.memo Doesn't Work

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

const MemoizedChild = React.memo(Child);

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

  const config = { theme: 'dark' }; // New object every render!

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedChild config={config} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: Every time you click the button, MemoizedChild re-renders, even though React.memo is supposed to prevent that.

Why?

  1. Clicking the button triggers Parent to re-render
  2. const config = { theme: 'dark' } creates a new object
  3. React compares the old and new config props: oldConfig === newConfig → false
  4. React thinks the props changed, so it re-renders MemoizedChild

The Same Problem with Arrays

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

  const items = [1, 2, 3]; // New array every render!

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedList items={items} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Same issue: items is a new array on every render, so MemoizedList always re-renders.


And Functions Too!

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

  const handleClick = () => { // New function every render!
    console.log('Clicked');
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedButton onClick={handleClick} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Same issue: handleClick is a new function on every render.


Part 3: Memoization to the Rescue

Memoization is the technique of caching values/computations so they're only recalculated when dependencies change.

React provides three hooks for memoization:

  1. useMemo — Memoize values (objects, arrays, expensive computations)
  2. useCallback — Memoize functions (special case of useMemo)
  3. React.memo — Memoize entire components

1. useMemo for Objects and Arrays

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

  const config = useMemo(() => ({ theme: 'dark' }), []); // Same object every render!

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedChild config={config} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • useMemo returns the same object ({ theme: 'dark' }) on every render
  • The object is only recreated if dependencies change (empty array [] = never)
  • Now oldConfig === newConfig → true, so MemoizedChild doesn't re-render

With Dependencies

function Parent({ theme }) {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({ theme }), [theme]); // Recreate only when 'theme' changes

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedChild config={config} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now:

  • When count changes, config stays the same (no re-render)
  • When theme changes, config is recreated (re-render happens, as expected)

2. useCallback for Functions

useCallback is shorthand for memoizing functions:

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

  const handleClick = useCallback(() => { // Same function every render!
    console.log('Clicked');
  }, []); // No dependencies = function never changes

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedButton onClick={handleClick} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Equivalent useMemo version:

const handleClick = useMemo(() => {
  return () => console.log('Clicked');
}, []);
Enter fullscreen mode Exit fullscreen mode

But useCallback is cleaner for functions.


With Dependencies

function Parent({ userId }) {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log(`Clicked for user ${userId}`);
  }, [userId]); // Recreate when 'userId' changes

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <MemoizedButton onClick={handleClick} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. React.memo for Components

React.memo is a Higher-Order Component that memoizes the entire component:

const Child = React.memo(function Child({ config }) {
  console.log('Child rendered');
  return <div>{config.theme}</div>;
});
Enter fullscreen mode Exit fullscreen mode

How it works:

  • React shallowly compares old and new props
  • If all props are referentially equal, skip re-render
  • If any prop changes (by reference), re-render

Important: React.memo uses shallow comparison:

// Shallow comparison (default)
oldProps.config === newProps.config // Checks reference only

// Deep comparison (custom)
React.memo(Child, (prevProps, nextProps) => {
  return prevProps.config.theme === nextProps.config.theme; // Custom logic
});
Enter fullscreen mode Exit fullscreen mode

Part 4: When to Use Memoization

Use Memoization When:

  1. Passing objects/arrays to memoized child components
const config = useMemo(() => ({ theme: 'dark' }), []);
<MemoizedChild config={config} />
Enter fullscreen mode Exit fullscreen mode
  1. Passing functions to memoized child components
const handleClick = useCallback(() => {}, []);
<MemoizedButton onClick={handleClick} />
Enter fullscreen mode Exit fullscreen mode
  1. Expensive computations
const sortedData = useMemo(() => {
  return data.sort((a, b) => a.value - b.value); // Expensive!
}, [data]);
Enter fullscreen mode Exit fullscreen mode
  1. Dependencies in useEffect or other hooks
const config = useMemo(() => ({ theme: 'dark' }), []);

useEffect(() => {
  fetchData(config); // Won't re-run unnecessarily
}, [config]);
Enter fullscreen mode Exit fullscreen mode

Don't Use Memoization When:

  1. Primitives (already compared by value)
// Unnecessary
const count = useMemo(() => 5, []);

// Just use the value
const count = 5;
Enter fullscreen mode Exit fullscreen mode
  1. Child doesn't re-render expensively
// Overkill if rendering <div>{text}</div> is cheap
const text = useMemo(() => someString, []);
Enter fullscreen mode Exit fullscreen mode
  1. Component always re-renders anyway
// No benefit if Child isn't memoized
const config = useMemo(() => ({ theme: 'dark' }), []);
<Child config={config} /> // Not wrapped in React.memo
Enter fullscreen mode Exit fullscreen mode
  1. Premature optimization

Don't memoize everything! Only optimize when you have a performance problem.


Part 5: Common Memoization Pitfalls

Pitfall 1: Missing Dependencies

function Component({ userId }) {
  const handleClick = useCallback(() => {
    console.log(`User: ${userId}`); // Uses 'userId'
  }, []); // Missing dependency!

  return <button onClick={handleClick}>Click</button>;
}
Enter fullscreen mode Exit fullscreen mode

Problem: handleClick closes over the initial userId and never updates.

Fix:

const handleClick = useCallback(() => {
  console.log(`User: ${userId}`);
}, [userId]); // Include 'userId'
Enter fullscreen mode Exit fullscreen mode

Pitfall 2: Memoizing Without Memoized Child

function Parent() {
  const config = useMemo(() => ({ theme: 'dark' }), []); // Useless!

  return <Child config={config} />; // Child isn't memoized
}

function Child({ config }) {
  return <div>{config.theme}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Problem: Child re-renders on every Parent render anyway (not wrapped in React.memo).

Fix:

const MemoizedChild = React.memo(Child); // Now useMemo helps

function Parent() {
  const config = useMemo(() => ({ theme: 'dark' }), []);
  return <MemoizedChild config={config} />;
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 3: Creating Objects Inside JSX

function Parent() {
  return <Child config={{ theme: 'dark' }} />; // New object every render!
}
Enter fullscreen mode Exit fullscreen mode

Fix:

function Parent() {
  const config = useMemo(() => ({ theme: 'dark' }), []);
  return <Child config={config} />; // Stable reference
}
Enter fullscreen mode Exit fullscreen mode

Pitfall 4: Memoizing Everything (Over-Optimization)

// Too much memoization
function Component() {
  const a = useMemo(() => 1, []);
  const b = useMemo(() => 2, []);
  const sum = useMemo(() => a + b, [a, b]);

  return <div>{sum}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Problem: useMemo has overhead. For simple computations, it's slower than just recalculating.

Better:

function Component() {
  const a = 1;
  const b = 2;
  const sum = a + b; // Fast enough without memoization

  return <div>{sum}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Part 6: Memoization Patterns in React

Pattern 1: Memoizing Context Values

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  // New object every render → all consumers re-render!
  // const value = { theme, setTheme };

  // Stable reference
  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Memoizing Expensive Filters

function UserList({ users, filter }) {
  const filteredUsers = useMemo(() => {
    return users.filter(user => {
      // Expensive filtering logic
      return user.name.toLowerCase().includes(filter.toLowerCase());
    });
  }, [users, filter]); // Only recompute when 'users' or 'filter' changes

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

Pattern 3: Memoizing Event Handlers with Parameters

function ItemList({ items, onDelete }) {
  return (
    <ul>
      {items.map(item => (
        <Item key={item.id} item={item} onDelete={onDelete} />
      ))}
    </ul>
  );
}

const Item = React.memo(function Item({ item, onDelete }) {
  // New function every render
  // const handleDelete = () => onDelete(item.id);

  // Memoized
  const handleDelete = useCallback(() => {
    onDelete(item.id);
  }, [item.id, onDelete]);

  return (
    <li>
      {item.name}
      <button onClick={handleDelete}>Delete</button>
    </li>
  );
});
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Using Refs for Stable References

Sometimes you need a value that doesn't trigger re-renders:

function Component() {
  const configRef = useRef({ theme: 'dark' });

  useEffect(() => {
    // Always uses the same object reference
    fetchData(configRef.current);
  }, []); // No dependencies needed

  return <div>Content</div>;
}
Enter fullscreen mode Exit fullscreen mode

Use case: When you need a stable reference but don't want to trigger re-renders when it changes.


Part 7: Debugging Referential Equality

Tool 1: Object.is() (React's Comparison)

React uses Object.is() for comparison (similar to === but handles NaN correctly):

const obj1 = { theme: 'dark' };
const obj2 = { theme: 'dark' };

console.log(Object.is(obj1, obj2)); // false (different references)

const obj3 = obj1;
console.log(Object.is(obj1, obj3)); // true (same reference)
Enter fullscreen mode Exit fullscreen mode

Tool 2: React DevTools Profiler

Use the React DevTools Profiler to see why components re-render:

  1. Open React DevTools
  2. Go to Profiler tab
  3. Start recording
  4. Interact with your app
  5. Look at "Why did this render?" section

Tool 3: useWhyDidYouUpdate Hook (Custom)

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

  useEffect(() => {
    if (previousProps.current) {
      const allKeys = Object.keys({ ...previousProps.current, ...props });
      const changedProps = {};

      allKeys.forEach(key => {
        if (previousProps.current[key] !== props[key]) {
          changedProps[key] = {
            from: previousProps.current[key],
            to: props[key]
          };
        }
      });

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

    previousProps.current = props;
  });
}

function MyComponent(props) {
  useWhyDidYouUpdate('MyComponent', props);
  return <div>Content</div>;
}
Enter fullscreen mode Exit fullscreen mode

Quick Reference Cheat Sheet

Hook Purpose Example
useMemo Memoize values (objects, arrays, computations) useMemo(() => ({ theme }), [theme])
useCallback Memoize functions useCallback(() => {}, [deps])
React.memo Memoize components React.memo(Component)
useRef Stable mutable reference (doesn't cause re-renders) useRef({ theme: 'dark' })

Key Takeaways

Objects and arrays are compared by reference, not by value: {} !== {}
Creating objects/arrays/functions on every render causes unnecessary re-renders in memoized components
useMemo stabilizes object/array references across renders
useCallback stabilizes function references (shorthand for useMemo with functions)
React.memo prevents component re-renders when props don't change (by reference)
Always include dependencies in useMemo and useCallback
Don't memoize primitives — they're already compared by value
Memoize context values to prevent unnecessary consumer re-renders
Don't over-optimize — memoization has overhead, only use it when needed


Interview Tip

When asked about referential equality and memoization:

  1. "In JavaScript, objects are compared by reference, not value, so {} !== {} even though they look identical"
  2. React's impact: "React uses referential equality to check if props changed. If you create a new object on every render, React thinks the props changed, causing re-renders"
  3. Solution: "useMemo and useCallback stabilize references across renders, preventing unnecessary re-renders in memoized components"
  4. Example: "If you pass config={{ theme: 'dark' }} to a memoized child, it re-renders every time. Using useMemo(() => ({ theme: 'dark' }), []) fixes it"
  5. Best practice: "Only memoize when you have a performance problem — measure first, optimize second"

Now go forth and never wonder why your memoized component keeps re-rendering again!h

Top comments (0)