≡ Menu
react shallow comparison

React applications, when not optimized, can suffer from unnecessary re-renders that impact performance.

If you’ve ever wondered why your component re-renders even when its visual output seems unchanged, the answer often lies in how React determines if something has “changed.”

Enter shallow comparison – a fundamental concept that, once understood, unlocks powerful optimization techniques in your React toolkit.

What Exactly Is Shallow Comparison?

 

At its core, shallow comparison is a quick way to check if two values are “equal.”

But its definition of equality is quite specific, especially when dealing with different data types:

  1. For Primitives (numbers, strings, booleans, null, undefined): It compares their actual values. If 5 is compared to 5, it’s true. If "hello" is compared to "world", it’s false. This is straightforward.
  2. For Objects and Arrays: This is where it gets interesting. Shallow comparison does not look inside the object or array to compare its contents. Instead, it compares their references (their memory addresses).
    • If two variables point to the exact same object in memory, the shallow comparison returns true.
    • If they point to different objects, even if those objects have identical content, the shallow comparison returns false.

Think of it like this:

  • 5 === 5 -> true (Values are identical)
  • { a: 1 } === { a: 1 } -> false (These are two distinct objects in memory, even though they look the same)
  • const obj1 = { a: 1 }; const obj2 = obj1; obj1 === obj2 -> true (Both obj1 and obj2 point to the same object)

This distinction is crucial for understanding React’s re-rendering behavior.

 

Why Does React Care About Shallow Comparison?

 

React’s primary goal is to efficiently update the UI. When a component’s state or props change, React needs to decide if it’s worth the effort to re-render that component and its children. This decision-making process is where shallow comparison shines as an optimization.

React provides two main tools that leverage shallow comparison to prevent unnecessary re-renders:

 

1. React.memo for Functional Components

 

If you have a functional component that often receives the same props, you can wrap it in React.memo.

import React, { memo } from 'react';

const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
  console.log('Rendering ProductCard:', product.name); // See when it renders

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price.toFixed(2)}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});

export default ProductCard;

With React.memo, if the product prop and onAddToCart prop are shallowly equal to what they were in the previous render, React will skip rendering ProductCard and reuse its last rendered output.

 

2. React.PureComponent for Class Components

 

For those still using class components, React.PureComponent offers similar memoization. If your class component extends PureComponent, React automatically implements a shouldComponentUpdate method that performs a shallow comparison of both this.props and this.state.

 

import React, { PureComponent } from 'react';

class UserDashboard extends PureComponent {
  // PureComponent automatically implements shouldComponentUpdate
  // with shallow comparison for props and state.
  render() {
    console.log('Rendering UserDashboard:', this.props.username);
    return (
      <div className="dashboard">
        <h2>Welcome, {this.props.username}!</h2>
        <p>You have {this.state.notifications} new messages.</p>
      </div>
    );
  }
}

 

The Immutable Update Imperative: Preventing Common Pitfalls

 

The power of shallow comparison comes with a crucial prerequisite: you must use immutable updates for objects and arrays in your state and props.

Why? Because if you directly mutate an object or array (change its contents without creating a new one), its memory reference remains the same. When React performs a shallow comparison, it will see the same reference and assume nothing has changed, thus skipping a necessary re-render. This leads to frustrating bugs where your UI doesn’t update even though your data has.

Common Pitfalls and How to Avoid Them:

 

Bad: Mutating an Array

 

const [items, setItems] = useState(['apple', 'banana']);

const addItemBad = () => {
  items.push('cherry'); // Mutates the existing array!
  setItems(items); // Array reference is unchanged. React.memo/PureComponent won't re-render.
};

 

Good: Immutable Array Update

 

const addItemGood = () => {
  setItems(prevItems => [...prevItems, 'cherry']); // Creates a NEW array
};

 

Bad: Mutating an Object

 

const [user, setUser] = useState({ id: 1, name: 'Alice', age: 30 });

const updateAgeBad = () => {
  user.age = 31; // Mutates the existing object!
  setUser(user); // Object reference is unchanged.
};

 

Good: Immutable Object Update

 

const updateAgeGood = () => {
  setUser(prevUser => ({
    ...prevUser, // Spreads all properties of the old object
    age: 31       // Overwrites or adds the 'age' property
  })); // Creates a NEW object
};

 

Bad: Unstable Function References

 

Functions defined directly within a functional component are re-created on every render, leading to a new reference. If passed as a prop to a React.memo child, it will cause an unnecessary re-render.

 

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

  //  This function gets a new reference on every render of Parent
  const handleAction = () => { /* ... */ }; 

  return <MemoizedChild action={handleAction} />;
}

 

Good: Stable Function References with useCallback

 

import React, { useState, useCallback } from 'react';

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

  //  This function maintains its reference as long as 'count' doesn't change
  const handleAction = useCallback(() => {
    // This function can safely use 'count' because it's in its dependency array
    console.log('Action for count:', count); 
  }, [count]); // Dependency array: only re-create if count changes

  return <MemoizedChild action={handleAction} />;
}

 

Live Demonstration: Shallow Comparison in Action

 

Let’s put this into practice with a quick example. We’ll have a ParentComponent and a MemoizedChild component. The parent will manage some state and pass props to the child.

// --- MemoizedChild.jsx ---
import React, { memo } from 'react';

function Child({ title, data, action }) {
  // This log helps us see when the component actually re-renders
  console.log(`--- Rendering: ${title} ---`); 
  
  return (
    <div style={{ margin: '10px', padding: '10px', border: '1px solid #ddd', borderRadius: '5px', backgroundColor: '#f9f9f9' }}>
      <h4>{title}</h4>
      <p>Data: **{data}**</p>
      <button onClick={action}>Run Action</button>
    </div>
  );
}

// Wrap the child with React.memo for shallow comparison
const MemoizedChild = memo(Child); 
export default MemoizedChild;


// --- ParentComponent.jsx ---
import React, { useState, useCallback } from 'react';
import MemoizedChild from './MemoizedChild';

function ParentComponent() {
  const [count, setCount] = useState(0);
  const [userProfile, setUserProfile] = useState({ id: 1, name: 'Alice' });

  // 1. Primitive state update
  const incrementCount = () => setCount(prev => prev + 1);

  // 2. Unstable function: new reference on every ParentComponent render
  const unstableAction = () => console.log('Unstable action triggered!');

  // 3. Stable function: uses useCallback to maintain reference
  const stableAction = useCallback(() => {
    console.log('Stable action triggered!');
    // This could hypothetically use 'count' if added to dependencies:
    // console.log('Stable action for count:', count);
  }, []); // Empty dependency array means this function reference is created once

  // 4. Immutable object update
  const changeUserName = () => {
    setUserProfile(prevProfile => ({
      ...prevProfile,
      name: prevProfile.name === 'Alice' ? 'Bob' : 'Alice'
    }));
  };

  return (
    <div style={{ padding: '20px', border: '2px solid #007bff', borderRadius: '8px' }}>
      <h1>Parent Component (Count: {count})</h1>
      <p>User: {userProfile.name}</p>
      <button onClick={incrementCount} style={{ marginRight: '10px' }}>
        Increment Count (Parent Rerenders)
      </button>
      <button onClick={changeUserName}>
        Change User Name (User Object Reference Changes)
      </button>

      <hr style={{ margin: '20px 0' }} />

      {/* Child 1: Receives unstable function prop */}
      <MemoizedChild 
        title="Child 1: Unstable Function Prop" 
        data={`Count: ${count}`} // Primitive prop: changes, so re-render is expected
        action={unstableAction} //  Unstable reference: causes re-render even if data was same
      />

      {/* Child 2: Receives stable function prop */}
      <MemoizedChild 
        title="Child 2: Stable Function Prop" 
        data={`User Name: ${userProfile.name}`} // Primitive prop: changes only when userProfile.name changes
        action={stableAction} //  Stable reference: will NOT cause re-render unless other props change
      />
      
      {/* Child 3: Illustrates object prop change */}
      <MemoizedChild 
        title="Child 3: Object Prop Change" 
        data={`User ID: ${userProfile.id}`} // Primitive prop: user ID doesn't change
        action={stableAction} //  Stable reference
        // user={userProfile} // If we passed the userProfile object, it would cause re-renders when userName changes
      />
    </div>
  );
}

export default ParentComponent;

Here’s how to interpret the console logs when interacting with this example:

  1. Click “Increment Count”:
    • ParentComponent re-renders (state count changed).
    • Child 1 renders: count prop (primitive) changed, and unstableAction is a new function reference. Both cause re-render.
    • Child 2 renders: userProfile.name prop (primitive) is unchanged, stableAction reference is unchanged. But count in its data prop changed. So it re-renders. (This is expected and correct).
    • Child 3 renders: userProfile.id prop (primitive) is unchanged, stableAction reference is unchanged. So it does not re-render if only count changes in the parent! It only re-renders if userProfile.id changes, which it doesn’t in this example.
  2. Click “Change User Name”:
    • ParentComponent re-renders (state userProfile changed).
    • Child 1 renders: count prop (primitive) is unchanged. unstableAction is a new function reference. Causes re-render.
    • Child 2 renders: userProfile.name prop (primitive) changed. Causes re-render.
    • Child 3 does NOT render: Its props (userProfile.id and stableAction) are shallowly equal to previous values.

This example clearly illustrates how React.memo (and PureComponent) relies on stable references for non-primitive props. When you ensure stability using useCallback for functions or creating new objects/arrays for state updates, you maximize the benefits of shallow comparison and significantly reduce unnecessary re-renders.

 

When NOT to use Shallow Comparison

 

While powerful, React.memo and PureComponent aren’t always necessary:

  • Components that re-render frequently anyway: If a component’s props almost always change, adding memoization might add overhead without performance benefits.
  • Components with very few props: The overhead of comparison might outweigh the cost of a simple re-render.
  • Components with deeply nested data structures: Shallow comparison won’t catch changes deep inside objects/arrays. For these, you might need a custom comparison function with React.memo or a state management library that encourages normalized state.

Conclusion

Shallow comparison is an indispensable concept for any React developer aiming for optimal performance. By understanding how it works and consistently applying immutable updates with useState, useReducer, useCallback, and useMemo, you can effectively leverage React.memo and React.PureComponent to build faster, more efficient React applications.

Keep those console logs open and watch your components render (or not render!) as you apply these techniques. Happy optimizing!

Useful links below:

Let me & my team build you a money making website/blog for your business https://bit.ly/tnrwebsite_service

Get Bluehost hosting for as little as $1.99/month (save 75%)…https://bit.ly/3C1fZd2

Join my Patreon for one-on-one coaching and help with your coding…https://www.patreon.com/c/TyronneRatcliff

Buy me a coffee ☕️https://buymeacoffee.com/tyronneratcliff

 

 

 

{ 0 comments }

Have you ever built an interactive web page, only to find your JavaScript becoming a tangled mess of event listeners?

Or perhaps you’ve struggled with handling clicks on dynamically added content? If so, it’s time to unlock the power of Event Delegation.

Event delegation is a fundamental pattern in web development that can drastically improve the performance, memory footprint, and maintainability of your JavaScript applications. Let’s dive in!

What’s the Problem Event Delegation Solves?

 

Imagine you have a long list of items, and you want to do something specific (like show an alert) when any of those items are clicked. Your first thought might be to attach a click listener to every single list item:

 

// A less optimal approach (without delegation):
document.querySelectorAll('.list-item').forEach(item => {
    item.addEventListener('click', function(event) {
        alert('Clicked: ' + event.target.textContent);
    });
});

While this works, it has a few drawbacks:

  1. Memory Overhead: For a list with 100 items, you’re creating 100 separate event listeners. That’s a lot of objects for the browser to keep track of.
  2. Performance on Load: The browser has to iterate through all elements and attach these listeners when the page loads, which can impact initial rendering time.
  3. Dynamic Content Issues: What happens if you add new list items after the page has loaded? These new items won’t have any listeners attached, and your code will fail to react to their clicks!

This is where Event Delegation shines.

The Power of Event Bubbling

 

Before we get to the solution, a quick word on event bubbling. When an event (like a click) occurs on an element, it doesn’t just happen on that element. It also “bubbles up” through its ancestors in the DOM tree. So, a click on a <li> also triggers a click event on its parent <ul>, its parent <body>, and so on, all the way up to the document object.

Event delegation leverages this bubbling mechanism.

How Event Delegation Works

 

Instead of attaching a listener to every child element, you attach a single event listener to a common ancestor element. When an event occurs on one of the children, it bubbles up to this ancestor. The ancestor’s listener then “catches” the event and, using the event.target property, determines which specific child element was originally clicked.

Let’s illustrate with a practical example:

Our HTML Structure

 

Consider a simple unordered list and a button to add new items dynamically:

<ul id="parent-list">
  <li class="list-item">Item 1</li>
  <li class="list-item">Item 2</li>
  <li class="list-item">Item 3</li>
  <li class="list-item">Item 4</li>
</ul>
<button id="add-btn">Add New Item</button>

JavaScript Implementation (Using Event Delegation)

// 1. Get the parent element where we'll attach our single listener
const parentList = document.getElementById('parent-list');
const addButton = document.getElementById('add-btn');

// 2. Attach ONE listener to the parent (the <ul> element)
parentList.addEventListener('click', function(event) {
  // 3. Check if the clicked element (event.target) matches the children we care about
  //    The `matches()` method is perfect for this.
  if (event.target && event.target.matches('.list-item')) {
    // 4. Perform the desired action
    alert('Clicked item text: ' + event.target.textContent);
    // You can add more complex logic here, perhaps using data attributes:
    // const itemId = event.target.dataset.id;
    // console.log(`Item with ID ${itemId} was clicked!`);
  }
});

// Example: Dynamically adding new items
let itemCount = 5;
addButton.addEventListener('click', () => {
  const newItem = document.createElement('li');
  newItem.className = 'list-item';
  newItem.textContent = 'Item ' + itemCount;
  parentList.appendChild(newItem);
  itemCount++;
  
  // Notice: We do NOT need to add a new event listener for `newItem`!
  // The delegated listener on `parentList` will automatically handle clicks on it.
});

 

Why This Is Better

 

  • Efficiency: Only one event listener is active for the entire list, regardless of how many items it contains.
  • Future-Proof: Any new <li> elements added to parent-list (even dynamically) will automatically be covered by the existing delegated listener. No need to re-attach listeners!
  • Cleaner Code: Your JavaScript stays much tidier without loops creating multiple identical listeners.

 

When to Use Event Delegation

 

Event delegation is an excellent choice for:

  • Large lists or tables: Where you have many similar, interactive elements.
  • Dynamically loaded content: When content is added, removed, or changed via AJAX or user interaction.
  • Performance-critical applications: To minimize memory usage and improve responsiveness.

Conclusion

 

Event delegation is a powerful, elegant solution to common challenges in DOM manipulation. By understanding event bubbling and strategically placing your listeners, you can write more efficient, performant, and maintainable JavaScript. Start delegating your events today and experience a smoother, more robust web application!

{ 0 comments }