diff --git a/content/blog/functions-are-killing-react-performance/app_rerender.png b/content/blog/functions-are-killing-react-performance/app_rerender.png new file mode 100644 index 00000000..684d7d89 Binary files /dev/null and b/content/blog/functions-are-killing-react-performance/app_rerender.png differ diff --git a/content/blog/functions-are-killing-react-performance/index.md b/content/blog/functions-are-killing-react-performance/index.md new file mode 100644 index 00000000..5777e449 --- /dev/null +++ b/content/blog/functions-are-killing-react-performance/index.md @@ -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 ( +
+

Shopping Cart

+ +

Total: ${getCost()}

+
+ ) +} +``` + +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 ( +
+

Shopping Cart

+ +

Total: ${totalCost}

+
+ ) +} +``` + + # 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 ( +
+
{item.name}
+
{item.price}
+ +
+ ) +} + +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 ( +
+
+

Shopping Cart

+ {items.map((item) => ( + + ))} +
+
+

Cart

+
+ Total: ${totalCost} +
+
+ {cart.map((item) => ( +
{item.name}
+ ))} +
+
+
+ ) +} +``` + +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. + +![`ShoppingItem key="1"` re-rendered because "Props changed: `addToCart`"](./why_rerender.png) + +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 ( +
+
+

Shopping Cart

+ {items.map((item) => ( + + ))} +
+ {/* ... */} +
+ ) +} +``` + +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: + +![App is the only component that re-renders thanks to "Hook 1 changed"](./app_rerender.png) + +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 +

{someFn()}

+``` + +This is bad because `someFn` is expensive. + +```jsx +

{renderSomeUI()}

+``` + + + + + + + + + + + + + + + +```jsx +const Comp = ({bool}) => { + const renderContents = () => { + return bool ?
:

+ } + + return

+ {renderContents()} +
+} + +const Comp2 = () => { + return React.createElement("div", {}, [ + renderContents() + ]) +} + +const Contents = () => { + return bool ?
:

+} + +const Comp3 = ({bool}) => { + + return

+ +
+} + +const Comp4 = ({bool}) => { + return React.createElement("div", {}, [ + React.createElement(Contents, {bool}, []) + ]) +} +``` diff --git a/content/blog/functions-are-killing-react-performance/why_rerender.png b/content/blog/functions-are-killing-react-performance/why_rerender.png new file mode 100644 index 00000000..c2820b99 Binary files /dev/null and b/content/blog/functions-are-killing-react-performance/why_rerender.png differ