πŸ‘¨β€πŸ’» berezin.dev

Keys Are Cooler Than You Think

five keys in the row

Have you ever found yourself writing a useEffect when creating a new component to track some changes? For instance, resetting local state when props change or updating a DOM element under certain conditions? If you have, then chances are you might not need useEffect at all. In fact, you can simplify and optimize your code.

Test Application Overview

Let’s consider an example application with a product page.

The application consists of a few simple components:

  • ProductSelector for displaying a list of products. Selecting a product from the list displays detailed information.
  • ProductCard for displaying detailed product information with the option to select the quantity and add it to the cart.

When you add a product to the cart, you can see a list of all the added products in the console. In a real-world scenario, this list could be used for the shopping cart component or sent to the backend.

Important note: For this example, I deliberately disabled StrictMode to make it more illustrative.

Resetting Local State

In the ProductCard component, there’s a common pattern of resetting the component’s state using useEffect:

function ProductCard({ id, name, image, description, price, onAdd }) {
  const [count, setCount] = React.useState(1);
  const [isAdded, setIsAdded] = React.useState(false);

  ...

  React.useEffect(() => {
    setCount(1);
    setIsAdded(false);
  }, [id]);

  return (
    <div className="product-card">
      ...
    </div>
  );
}

Here, we respond to changes in props, specifically the unique product ID, to reset the item count and notification flag when new props are received.

The issue with this approach is that it triggers unnecessary component re-renders when resetting parameters, which can impact application performance.

What I mean by unnecessary component re-renders is best understood by examining the component’s lifecycle step by step:

Initial Component Setup

  1. Local State: Variables for storing and managing the state of the counter and notification display are created.

  2. Local Component Functions: Local functions for adding the product to the cart and managing the counter are created.

  3. useEffect: In React, effects are executed after the initial render, as part of the component’s lifecycle. In this example, useEffect doesn’t trigger another render because the values of the local state aren’t changing. As a result, the following logs will be displayed:

> render ProductCard 
> useEffect ProdcutCard

Here, “useEffect ProductCard” indicates that React initiated and executed useEffect after the initial render.

  1. Rendering the React DOM Tree: The DOM tree of the component is constructed and subsequently rendered by the browser.

Component Update Process

When a different product is selected from the list, the ProductCard component receives new props and triggers the re-rendering process. Let’s see what happens in this case:

  1. Local State: The state is already initialized, so this step is skipped.

  2. Local Component Functions: These functions are also skipped, although they will be reinitialized. While the useCallback hook can help with this, I kept this example simple for clarity.

  3. useEffect: Here’s the interesting part. In the console, you’ll see the following:

> render ProductCard 
> useEffect ProductCard
> render ProductCard 

Let’s break down the logs step by step:

  1. “render ProductCard” – This log is displayed because we passed new props, and React initiated the re-rendering process.
  2. “useEffect ProductCard” – At this moment, useEffect also responds because the dependency list contains the product id, which has changed.
  3. “render ProductCard” – But where did this additional re-render come from? It turns out that this re-render is initiated when setting states inside useEffect. As we know, every change to a setX function triggers a re-render. In this case, the “setCount(1)” and “setIsAdded(false)” functions cause a subsequent re-render, which is unnecessary.

To be more accurate, the re-rendering process with logs should look like this:

// First rerender
> render ProductCard 
> useEffect ProdcutCard

// Second rerender
> render ProductCard 

Fortunately, this component isn’t too resource-intensive, so the extra re-render may not be very noticeable. However, in production with complex component logic, these unnecessary re-renders can significantly impact performance.

So, how can we eliminate this redundant re-render and optimize React’s behavior? This is where keys come to the rescue.

Using Keys for Cache Clearing

As we know, keys in React are used to optimize performance (caching) so that React doesn’t waste resources on unnecessary re-renders. In this case, we can use them in a slightly different way.

Let’s start by moving our form into a separate component called ProductCardForm. We’ve moved all the local states and functions inside it:

import React from "react";

function ProductCardForm({ id, name, onAdd }) {
  const [count, setCount] = React.useState(1);
  const [isAdded, setIsAdded] = React.useState(false);

  const handleAddToCart = (event) => {
    event.preventDefault();
    onAdd({
      id,
      name,
      count
    });
    setIsAdded(true);
  };

  const handleDecrease = () => {
    if (count > 1) {
      const nextValue = count - 1;
      setCount(nextValue);
    }
  };

  const handleIncrease = () => {
    const nextValue = count + 1;
    setCount(nextValue);
  };

  if (isAdded) {
    return <p className="product-card-items-added">Items added to cart!</p>;
  }

  return (
    <form className="product-card-form" onSubmit={handleAddToCart}>
      <p>Number of items: </p>
      <div className="counter">
        <button type="button" onClick={handleDecrease}>
          -
        </button>
        <span>{count}</span>
        <button type="button" onClick={handleIncrease}>
          +
        </button>
      </div>
      <button type="submit" className="product-card-submit">
        Add to cart
      </button>
    </form>
  );
}

export default React.memo(ProductCardForm);

Additionally, we’ve simplified the ProductCard component, removed the useEffect from it, and added a subtle key prop to ProductCardForm:

import React from "react";

import ProductCardForm from "./ProductCardForm";

import { formatPrice } from "../utils";

function ProductCard({ id, name, image, description, price, onAdd }) {
  console.log("render ProductCard");

  return (
    <div className="product-card">
      <img className="product-card-image" alt={name} src={image} />
      <div className="product-card-details">
        <h1 className="product-card-title">{name}</h1>
        <p className="product-card-description">
          <b>Description</b>: {description}
        </p>
        <div className="product-card-price">
          <p>{formatPrice(price, "USD")}</p>
        </div>
        <ProductCardForm key={id} id={id} name={name} onAdd={onAdd} />
      </div>
    </div>
  );
}

export default ProductCard;

Now, when switching between products, we only see one component re-render:

> render ProductCard

The secret to this optimization and resetting of local states when switching products lies in the key prop. Essentially, we’re telling React to remember this component (cache it), and when the key changes, recreate it with new data. That’s why we no longer need useEffect.

This represents an optimized approach. You can verify that it works by comparing the results when adding and removing the key prop, and then try changing the quantity of products and switching between them.


Animations

Let’s add some dynamics to our application. We’ll make it so that when switching between products, there’s an animation for the price.

Now, when you select a product, a beautiful animation occurs, and the price slides out from under the product image.

However, there’s a problem here. This animation only happens on the initial product selection. In subsequent selections, it doesn’t work because of how the animation property behavesβ€”it only triggers during class initialization.

We can try to fix this by moving class initialization into a variable and displaying it conditionally or by adding the same useEffect. But there’s a much simpler way. We’ll just add a key prop with the product’s id to the tag displaying the price:

  ...
  <p key={id} className="animation">{formatPrice(price, "USD")}</p>
  ...

This way, we’re telling React to specifically recreate this element when the product ID changes, i.e., when switching products. This is exactly what we want!

Here’s the updated example with the price animation:


Conclusion

We’ve seen through examples that keys in React are a powerful tool for optimizing and simplifying code. They can be used not only for optimizing lists but also for clearing local state or restarting animations.

I hope this article was helpful, and you’ve gained something valuable for your future projects. Perhaps you’ll find ways to better optimize your current project.

Happy coding πŸ™Œ