Alexandro castro
Lisbon, Portugal
Blog/react-immutable-state-and-avoiding-rerenders-on-map-6

Introduction

TL;DR

I have working with React a few time, and I have been using void functions to update state, redux toolkit to handle with global state, zustand, and other libraries. But I have never been able to understand how React handle with rerendering inside `Map`, and how to avoid unnecessary rerendering..

Hello folks, happy you reach this guide.

Motivation

I have working with React a few time, and I have been using void functions to update state, redux toolkit to handle with global state, zustand, and other libraries. But I have never been able to understand how React handle with rerendering inside Map, and how to avoid unnecessary rerendering.

I also have been working with React Profile to check the rerender of components, and i notice some weird behavior, like a component that is not being updated, but it is rerendering.

Observations

1 - To guide us, i will use new Date().getTime() to check the time of the render, and i will use a Map to store the components.

2 - Note that i'm not talking about useMemo or useCallback, i'm talking about the rerendering of components inside Map.

Let me give you some examples.

The Weird Behaviour

Example 1

Imagine you have a component that receive those properties: id, title, price, onAdd, onRemove
You can find this in src/components/test-1/page.tsx

I started this component using:

...
                {cart.map((item) => {
                    const { id, name, quantity } = item
                    return (
                        <li key={id}>
                            <p>{name}</p>
                            <p role="timer">{new Date().getTime()}</p>
                            <button
                                aria-label={`item-${id}-remove`}
                                onClick={() => handleRemoveQuantity(id)}
                            >
                                -1
                            </button>
                            <span>{quantity}</span>
                            <button
                                aria-label={`item-${id}-add`}
                                onClick={() => handleAddQuantity(id)}
                            >
                                +1
                            </button>
                        </li>
                    )
                })}
...

This cause a weird behavior, because the component is rerendering every time that the cart is updated, even if the component is not being updated.

So to solve this, we can use 3 different approaches together:
1 - Use React.memo to avoid unnecessary rerendering.
2 - Use useCallback to avoid unnecessary recreation of functions.
3 - Use function binding to avoid unnecessary recreation of functions.

.........
 {cart.map((item) => (
    <ListItem
        key={item.id}
        id={item.id}
        name={item.name}
        quantity={item.quantity}
        handleRemoveQuantity={handleRemove} // 3 => function binding
        handleAddQuantity={handleAdd} // 3 => function binding
    />
))}
.........


const ListItem = React.memo( // 1 => React.memo
    ({ id, name, quantity, handleRemoveQuantity, handleAddQuantity }) => {
        return (
            <li key={id}>
                <p>{name}</p>
                <p role="timer">{new Date().getTime()}</p>
                <button
                    aria-label={`item-${id}-remove`}
                    onClick={() => handleRemoveQuantity(id)}
                >
                    -1
                </button>
                <span>{quantity}</span>
                <button
                    aria-label={`item-${id}-add`}
                    onClick={() => handleAddQuantity(id)}
                >
                    +1
                </button>
            </li>
        )
    }
)

...........
// 2 => useCallback
// React work with batch update, so the state will be updated
// that's why i use the prevState to update the state
const handleAddQuantity = React.useCallback((id: number) => {
        setCart((prevState) => {
            const cartCopy = [...prevState];
            const index = cartCopy.findIndex(currentItem => currentItem.id === id)
            cartCopy[index].addQuantity()

            return cartCopy
        })
}, [])

const handleRemoveQuantity = React.useCallback((id: number) => {
setCart((prevState) => {
    const cartCopy = [...prevState];
    const index = cartCopy.findIndex(currentItem => currentItem.id === id)
    cartCopy[index].removeQuantity()

    return cartCopy
})
}, [])

Here we have a weird behaviour yet, why ? Because handleAddQuantity and handleRemoveQuantity are functions, and they are being recreated every time that the component is rerendered.

So to solve this, we can use useCallback to avoid unnecessary recreation of functions.

export default function Home() {
    const { cart, handleAddQuantity, handleRemoveQuantity } = useCart()

    // If you wont use the useCallback hook, the function will change reference
    // and the ListItem will be re-rendered
    const handleAdd = React.useCallback((id: number) => {
        handleAddQuantity(id)
    }, [])

    // If you wont use the useCallback hook, the function will change reference
    // and the ListItem will be re-rendered
    const handleRemove = React.useCallback((id: number) => {
        handleRemoveQuantity(id)
    }, [])

    return (
        <main>
            <h1>Hook App</h1>
            <ul>
                {cart.map((item) => (
                    <ListItem
                        key={item.id}
                        id={item.id}
                        name={item.name}
                        quantity={item.quantity}
                        handleRemoveQuantity={handleRemove}
                        handleAddQuantity={handleAdd}
                    />
                ))}
            </ul>
        </main>
    )
}

Something interesting to note is that, if you are storing Classes as Cart Items, it will continue working, because the reference of the function will not change.

Solving with Zustand

Based on state/cart.ts, we can create a state using zustand.

handleAddQuantity: (id) => {
        const index = get().cart.findIndex((currentItem) => currentItem.id === id)
        set(
            produce((state: ICartState) => {
                state.cart[index].quantity += 1
            })
        )
    },
// It works without immer, but I'm not sure if it's the best way to do it
handleRemoveQuantity: (id) => {
    const cartCopy = [...get().cart]
    const index = get().cart.findIndex((currentItem) => currentItem.id === id)

    cartCopy[index] = {
        ...cartCopy[index],
        quantity: cartCopy[index].quantity - 1,
    }
    set({ cart: cartCopy })
}

and then we can use it in src/pages/test-3/page.tsx

const cart = useCartState((state) => state.cart)
const handleRemoveQuantity = useCartState((state) => state.handleRemoveQuantity)
const handleAddQuantity = useCartState((state) => state.handleAddQuantity)

PS: It's a good practice use selector each selector in a different line, to avoid unnecessary rerendering.

Solving with Context API

Based on src/pages/test-4/page.tsx, i repeat the component just to test if calling function on listItem directly from Context will cause a rerendering. But of course, it will.

Because the context will be updated, and the component will be rerendered.

const { cart, handleRemoveQuantity, handleAddQuantity } = useCartContext()
const item = cart.find((item) => item.id === id)
const { name, quantity } = item

return (
    <li key={id}>
        <p>{name}</p>
        <p role="timer">{new Date().getTime()}</p>
        <button
            aria-label={`item-${id}-remove`}
            onClick={() => handleRemoveQuantity(id)}
        >
            -1
        </button>
        <span>{quantity}</span>
        <button
            aria-label={`item-${id}-add`}
            onClick={() => handleAddQuantity(id)}
        >
            +1
        </button>
    </li>
)

Conclusion

So, what's the best way to do it ?

Each approach has its pros and cons, you can use the one that fits better for your use case.

1 - You can use use-context-selector by dai-shi
2 - You can use Zustand
3 - You can use Context API
4 - You can use Redux

etc...

SO be careful, and always use the React DevTools to check if your component is being rerendered unnecessarily. And if it is, try to solve it.

You should also be careful with handleChange ( inputs updates ) functions, because they can cause unnecessary rerendering too.