mirror of
https://github.com/LukeHagar/unicorn-utterances.git
synced 2025-12-07 12:57:45 +00:00
chore: initial outline for the article
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
366
content/blog/functions-are-killing-react-performance/index.md
Normal file
366
content/blog/functions-are-killing-react-performance/index.md
Normal file
@@ -0,0 +1,366 @@
|
||||
---
|
||||
{
|
||||
title: "Functions Are Killing Your React App's Performance",
|
||||
description: "",
|
||||
published: '2023-04-14T21:52:59.284Z',
|
||||
authors: ['crutchcorn'],
|
||||
tags: ['react'],
|
||||
attached: [],
|
||||
license: 'cc-by-4'
|
||||
}
|
||||
---
|
||||
|
||||
Functions are an integral part of all JavaScript applications; React apps included. [While I've written about how peculiar their usage can be, thanks to the fact that all functions are values](https://unicorn-utterances.com/posts/javascript-functions-are-values), they help split up the monotony of your codebase by splitting similar code into logical segments.
|
||||
|
||||
This knowledge that functions are values can assist you when working on improving your React apps' performance.
|
||||
|
||||
Let's look at some of the ways that functions often slow down React applications, why they do so, and how to combat them in our own apps.
|
||||
|
||||
In this adventure, we'll see how to:
|
||||
|
||||
- [Memoize return values with `useMemo`](#use-memo)
|
||||
- Create function stability with `useCallback`
|
||||
- Remove costly render functions with component extraction
|
||||
- Handle mandatory children functions performantly
|
||||
|
||||
# Memoizing Return Values with `useMemo` {#use-memo}
|
||||
|
||||
Let's say that we're building an ecommerce application and want to calculate the sum of all items in the cart:
|
||||
|
||||
```jsx
|
||||
const ShoppingCart = ({items}) => {
|
||||
const getCost = () => {
|
||||
return items.reduce((total, item) => {
|
||||
return total + item.price;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Shopping Cart</h1>
|
||||
<ul>
|
||||
{items.map(item => <li>{item.name}</li>)}
|
||||
</ul>
|
||||
<p>Total: ${getCost()}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This should show all items and the total cost, but this may cause headaches when `ShoppingCart` re-renders.
|
||||
|
||||
After all, a React functional component is a normal function, after all, and will be ran like any other; where `getCost` is recalculated on subsequent renders when you don't memoize the value.
|
||||
|
||||
This `getCost` function may not be overly expensive when there's only one or two items in the cart, but this can easily become a costly computation when there are 50 items or more in the cart.
|
||||
|
||||
The fix? Memoize the function call using `useMemo` so that it only re-runs when the `items` array changes:
|
||||
|
||||
```jsx
|
||||
const ShoppingCart = ({items}) => {
|
||||
const totalCost = useMemo(() => {
|
||||
return items.reduce((total, item) => {
|
||||
return total + item.price;
|
||||
}, 0);
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Shopping Cart</h1>
|
||||
<ul>
|
||||
{items.map(item => <li>{item.name}</li>)}
|
||||
</ul>
|
||||
<p>Total: ${totalCost}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
# Function Instability Causes Re-renders
|
||||
|
||||
Let's expand this shopping cart example by adding in the ability to add new items to the shopping cart.
|
||||
|
||||
```jsx
|
||||
import {useState, useMemo} from 'react';
|
||||
import {v4 as uuid} from 'uuid';
|
||||
|
||||
const ShoppingItem = ({item, addToCart}) => {
|
||||
return (
|
||||
<div>
|
||||
<div>{item.name}</div>
|
||||
<div>{item.price}</div>
|
||||
<button onClick={() => addToCart(item)}>Add to cart</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ id: 1, name: 'Milk', price: 2.5 },
|
||||
{ id: 2, name: 'Bread', price: 3.5 },
|
||||
{ id: 3, name: 'Eggs', price: 4.5 },
|
||||
{ id: 4, name: 'Cheese', price: 5.5 },
|
||||
{ id: 5, name: 'Butter', price: 6.5 }
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [cart, setCart] = useState([])
|
||||
|
||||
const addToCart = (item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}
|
||||
|
||||
const totalCost = useMemo(() => {
|
||||
return cart.reduce((acc, item) => acc + item.price, 0)
|
||||
}, [cart]);
|
||||
|
||||
return (
|
||||
<div style={{display: 'flex', flexDirection: 'row', flexWrap: 'nowrap'}}>
|
||||
<div style={{padding: '1rem'}}>
|
||||
<h1>Shopping Cart</h1>
|
||||
{items.map((item) => (
|
||||
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{padding: '1rem'}}>
|
||||
<h2>Cart</h2>
|
||||
<div>
|
||||
Total: ${totalCost}
|
||||
</div>
|
||||
<div>
|
||||
{cart.map((item) => (
|
||||
<div key={item.id}>{item.name}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
If I now click any of the items' `Add to cart` buttons, it will:
|
||||
|
||||
1) Trigger the `addToCart` function
|
||||
2) Update the `cart` array using `setCart`
|
||||
1) Generating [a new UUIDv4](https://unicorn-utterances.com/posts/what-are-uuids) for the item in the cart
|
||||
3) Cause the `App` component to re-render
|
||||
4) Update the displayed items in the cart
|
||||
5) Re-run the `totalCost` `useMemo` calculation
|
||||
|
||||
This is exactly what we'd expect to see in this application. However, if we open [the React Developer Tools](https://beta.reactjs.org/learn/react-developer-tools), and inspect [our Flame Chart](https://reactjs.org/blog/2018/09/10/introducing-the-react-profiler.html#flame-chart), we'll see that all `ShoppingItem` components are re-rendering, despite none of the passed `item`s changing.
|
||||
|
||||

|
||||
|
||||
The reason these components are re-rendering is because our `addToCart` property is changing.
|
||||
|
||||
> That's not right! We're always passing the same `addToCart` function on each render!
|
||||
|
||||
While this may seem true at a cursory glance, we can check this with some additional logic:
|
||||
|
||||
```jsx
|
||||
// This is not good production code, but is used to demonstrate a function's reference changing
|
||||
export default function App() {
|
||||
const [cart, setCart] = useState([])
|
||||
|
||||
const addToCart = (item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (window.addToCart) {
|
||||
console.log("addToCart is the same as the last render?", window.addToCart === addToCart);
|
||||
}
|
||||
|
||||
window.addToCart = addToCart;
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
This code:
|
||||
|
||||
- Sets up `addToCart` function inside of the `App`
|
||||
- Runs [a layout effect](https://beta.reactjs.org/reference/react/useLayoutffect) on every render to:
|
||||
- Assign `addToCart` to `window.addToCart`
|
||||
- Checks if the old `window.addToCart` is the same as the new one
|
||||
|
||||
With this code, we would expect to see `true` if the function is not reassigned between renders. However, we instead see:
|
||||
|
||||
> addToCart is the same as the last render? false
|
||||
|
||||
This is because, despite having the same name between renders, a new function _reference_ is created for each component render.
|
||||
|
||||
Think of it this way: Under-the-hood, React calls each (functional) component as just that - a function.
|
||||
|
||||
Imagine we're React for a moment and have this component:
|
||||
|
||||
```js
|
||||
// This is not a real React component, but is a function we're using in place of a functional component
|
||||
const component = ({items}) => {
|
||||
const addToCart = (item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}
|
||||
|
||||
return {addToCart};
|
||||
}
|
||||
```
|
||||
|
||||
If we, acting as React, call this `component` multiple times:
|
||||
|
||||
```js
|
||||
// First "render"
|
||||
const firstAddToCart = component().addToCart;
|
||||
// Second "render"
|
||||
const secondAddToCart = component().addToCart;
|
||||
|
||||
// `false`
|
||||
console.log(firstAddToCart === secondAddToCart);
|
||||
```
|
||||
|
||||
We can see a bit more clearly why `addToCart` is not the same between renders; it's a new function defined inside of the scope of another function.
|
||||
|
||||
## Create function stability with `useCallback` {#use-callback}
|
||||
|
||||
So, if our `ShoppingItem` is re-rendering because our `addToCart` function is changing, how do we fix this?
|
||||
|
||||
Well, we know [from the previous section that we can use `useMemo` to cache a function's return between component renders](#use-memo); what if used that here as well?
|
||||
|
||||
```jsx
|
||||
export default function App() {
|
||||
const [cart, setCart] = useState([])
|
||||
|
||||
const addToCart = useMemo(() => {
|
||||
return (item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ...
|
||||
|
||||
return (
|
||||
<div style={{display: 'flex', flexDirection: 'row', flexWrap: 'nowrap'}}>
|
||||
<div style={{padding: '1rem'}}>
|
||||
<h1>Shopping Cart</h1>
|
||||
{items.map((item) => (
|
||||
<ShoppingItem key={item.id} item={item} addToCart={addToCart} />
|
||||
))}
|
||||
</div>
|
||||
{/* ... */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Here, we're telling React never to re-initialize the `addToCart` function by memoizing the logic inside of a `useMemo`.
|
||||
|
||||
We can validate this by looking at our flame chart in the React DevTools again:
|
||||
|
||||

|
||||
|
||||
And re-checking the function reference stability using our `window` trick:
|
||||
|
||||
```jsx
|
||||
// ...
|
||||
|
||||
const addToCart = useMemo(() => {
|
||||
return (item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (window.addToCart) {
|
||||
console.log("addToCart is the same as the last render?", window.addToCart === addToCart);
|
||||
}
|
||||
|
||||
window.addToCart = addToCart;
|
||||
});
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
> addToCart is the same as the last render? true
|
||||
|
||||
This use-case of memoizing an inner function is so common that it even has a shortform helper called `useCallback`:
|
||||
|
||||
```jsx
|
||||
const addToCart = useMemo(() => {
|
||||
return (item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}
|
||||
}, []);
|
||||
|
||||
// These two are equivilant to one another
|
||||
|
||||
const addToCart = useCallback((item) => {
|
||||
setCart(v => [...v, {...item, id: uuid()}])
|
||||
}, []);
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
```jsx
|
||||
<p>{someFn()}</p>
|
||||
```
|
||||
|
||||
This is bad because `someFn` is expensive.
|
||||
|
||||
```jsx
|
||||
<p>{renderSomeUI()}</p>
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
```jsx
|
||||
const Comp = ({bool}) => {
|
||||
const renderContents = () => {
|
||||
return bool ? <div/> : <p/>
|
||||
}
|
||||
|
||||
return <div>
|
||||
{renderContents()}
|
||||
</div>
|
||||
}
|
||||
|
||||
const Comp2 = () => {
|
||||
return React.createElement("div", {}, [
|
||||
renderContents()
|
||||
])
|
||||
}
|
||||
|
||||
const Contents = () => {
|
||||
return bool ? <div/> : <p/>
|
||||
}
|
||||
|
||||
const Comp3 = ({bool}) => {
|
||||
|
||||
return <div>
|
||||
<Contents bool={bool}/>
|
||||
</div>
|
||||
}
|
||||
|
||||
const Comp4 = ({bool}) => {
|
||||
return React.createElement("div", {}, [
|
||||
React.createElement(Contents, {bool}, [])
|
||||
])
|
||||
}
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Reference in New Issue
Block a user