Performance in React
Once a React application grows, maintenance becomes a priority. To prepare for this eventuality, we’ll cover performance optimization, type safety, testing, and project structure. Each can strengthen your app to take on more functionality without losing quality.
Performance optimization prevents applications from slowing down by assuring efficient use of available resource. Typed programming languages like TypeScript detect bugs earlier in the feedback loop. Testing gives us more explicit feedback than typed programming, and provides a way to understand which actions can break the application. Lastly, project structure supports the organized management of assets into folders and files, which is especially useful in scenarios where team members work in different domains.
Performance in React
This section is just here for the sake of learning about performance improvements in React. We wouldn’t need optimizations in most React applications, as React is fast out of the box. While more sophisticated tools exist for performance measurements in JavaScript and React, we will stick to a simple console.log()
and our browser’s developer tools for the logging output.
Don’t run on first render
Previously we covered React’s useEffect Hook, which is used for side-effects. It runs the first time a component renders (mounting), and then every re-render (updating). By passing an empty dependency array to it as a second argument, we can tell the hook to run on the first render only. Out of the box, there is no way to tell the hook to run only on every re-render (update) and not on the first render (mount). For instance, examine this custom hook for state management with React’s useStat
e Hook and its semi persistent state with local storage using React’s useEffect
Hook:
1const useSemiPersistentState = (key, initialState) => {2 const [value, setValue] = React.useState(3 localStorage.getItem(key) || initialState4 );56 React.useEffect(() => {7 console.log('A');89 localStorage.setItem(key, value);10 }, [value, key]);1112 return [value, setValue];13};
With a closer look at the developer’s tools, we can see the log for a first time render of the component using this custom hook. It doesn’t make sense to run the side-effect for the initial rendering of the component, because there is nothing to store in the local storage except the initial value. It’s a redundant function invocation, and should only run for every update (re-rendering) of the component.
As mentioned, there is no React Hook that runs on every re-render, and there is no way to tell the useEffect
hook in a React idiomatic way to call its function only on every re-render. However, by using React’s useRef Hook which keeps its ref.current
property intact over re-renders, we can keep a made up state (without re-rendering the component on state updates) of our component’s lifecycle:
1const useSemiPersistentState = (key, initialState) => {23 const isMounted = React.useRef(false);456 const [value, setValue] = React.useState(7 localStorage.getItem(key) || initialState8 );910 React.useEffect(() => {1112 if (!isMounted.current) {13 isMounted.current = true;14 } else {1516 console.log('A');17 localStorage.setItem(key, value);1819 }2021 }, [value, key]);2223 return [value, setValue];24};
We are exploiting the ref
and its mutable current
property for imperative state management that doesn’t trigger a re-render. Once the hook is called from its component for the first time (component render), the ref’s current
is initialized with a false
boolean called isMounted
. As a result, the side-effect function in useEffect
isn’t called; only the boolean flag for isMounted
is toggled to true
in the side-effect. Whenever the hook runs again (component re-render), the boolean flag is evaluated in the side-effect. Since it’s true
, the side-effect function runs. Over the lifetime of the component, the isMounted
boolean will remaintrue
. It was there to avoid calling the side-effect function for the first time render that uses our custom hook.
The above was only about preventing the invocation of one simple function for a component rendering for the first time. But imagine you have an expensive computation in your side-effect, or the custom hook is used frequently in the application. It’s more practical to deploy this technique to avoid unnecessary function invocations.
Don’t re-render if not needed
Earlier, we explored React’s re-rendering mechanism. We’ll repeat this exercise for the App and List components. For both components, add a logging statement.
1const App = () => {2 .....34 console.log('B:App');5 return ( .... );6};78const List = ({ list, onRemoveItem }) =>910 console.log('B:List') ||1112 list.map(item => (13 <Item14 key={item.objectID}15 item={item}16 onRemoveItem={onRemoveItem}17 />18 ));
Because the List component has no function body, and developers are lazy folks who don’t want to refactor the component for a simple logging statement, the List component uses the ||
operator instead. This is a neat trick for adding a logging statement to a function component without a function body. Since the console.log()
on the left hand side of the operator always evaluates to false, the right hand side of the operator gets always executed.
1function getTheTruth() {2 if (console.log('B:List')) {3 return true;4 } else {5 return false;6 }7}89console.log(getTheTruth());10// B:List11// false
Let’s focus on the actual logging in the browser’s developer tools. You should see a similar output. First the App component renders, followed by its child components (e.g. List component).
1B:App2B:List3B:App4B:App5B:List
Since a side-effect triggers data fetching after the first render, only the App component renders, because the List component is replaced by a loading indicator in a conditional rendering. Once the data arrives, both components render again.
1// initial render2B:App3B:List45// data fetching with loading6B:App78// re-rendering with data9B:App10B:List
So far, this behavior is acceptable, since everything renders on time. Now we’ll take this experiment a step further, by typing into the SearchForm component’s input field. You should see the changes with every character entered into the element:
1B:App2B:List
But the List component shouldn’t re-render. The search feature isn’t executed via its button, so the list
passed to the List component should remain the same. This is React’s default behavior, which surprises many people.
If a parent component re-renders, its child components re-render as well. React does this by default, because preventing a re-render of child components could lead to bugs, and the re-rendering mechanism of React is still fast.
Sometimes we want to prevent re-rendering, however. For instance, huge data sets displayed in a table shouldn’t re-render if they are not affected by an update. It’s more efficient to perform an equality check if something changed for the component. Therefore, we can use React’s memo API to make this equality check for the props:
1const List = React.memo(2 ({ list, onRemoveItem }) =>3 console.log('B:List') ||4 list.map(item => (5 <Item6 key={item.objectID}7 item={item}8 onRemoveItem={onRemoveItem}9 />10 ))1112);
However, the output stays the same when typing into the SearchForm’s input field:
1B:App2B:List
The list
passed to the List component is the same, but the onRemoveItem
callback handler isn’t. If the App component re-renders, it always creates a new version of this callback handler. Earlier, we used React’s useCallacbk Hook to prevent this behavior, by creating a function only on a re-render (if one of its dependencies has changed).
1const App = () => {2 ...345 const handleRemoveStory = React.useCallback(item => {67 dispatchStories({8 type: 'REMOVE_STORY',9 payload: item,10 });1112 }, []);131415 ...1617 console.log('B:App');1819 return (... );20};
Since the callback handler gets the item
passed as an argument in its function signature, it doesn’t have any dependencies and is declared only once when the App component initially renders. None of the props passed to the List component should change now. Try it with the combination of memo
and useCallback
, to search via the SearchForm’s input field. The “B:List” output disappears, and only the App component re-renders with its “B:App” output.
While all props passed to a component stay the same, the component renders again if its parent component is forced to re-render. That’s React’s default behavior, which works most of the time because the re-rendering mechanism is fast enough. However, if re-rendering decreases the performance of a React application, memo
helps prevent re-rendering.
Sometimes memo
alone doesn’t help, though. Callback handlers are re-defined each time in the parent component and passed as changed props to the component, which causes another re-render. In that case, useCallback
is used for making the callback handler only change when its dependencies change.
Don’t rerun expensive computations
Sometimes we’ll have performance-intensive computations in our React components – between a component’s function signature and return block – which run on every render. For this scenario, we must create a use case in our current application first.
1const getSumComments = stories => {2 console.log('C');34 return stories.data.reduce(5 (result, value) => result + value.num_comments,6 07 );8};91011const App = () => {12 ...1314 const sumComments = getSumComments(stories);1516 return (17 <div>1819 <h1>My Hacker Stories with {sumComments} comments.</h1>2021 ...22 </div>23 );24};
If all arguments are passed to a function, it’s acceptable to have it outside the component. It prevents creating the function on every render, so the useCallback
hook becomes unnecessary. The function still computes the value of summed comments on every render, which becomes a problem for more expensive computations.
Each time text is typed in the input field of the SearchForm component, this computation runs again with an output of “C”. This may be fine for a non-heavy computation like this one, but imagine this computation would take more than 500ms. It would give the re-rendering a delay, because everything in the component has to wait for this computation. We can tell React to only run a function if one of its dependencies has changed. If no dependency changed, the result of the function stays the same. React’s useMemo Hook helps us here:
1const App = () => {2 ...34 const sumComments = React.useMemo(() => getSumComments(stories), [5 stories,6 ]);789 return ( ... );10};
For every time someone types in the SearchForm, the computation shouldn’t run again. It only runs if the dependency array, here stories
, has changed. After all, this should only be used for cost expensive computations which could lead to a delay of a (re-)rendering of a component.
Now, after we went through these scenarios for useMemo
, useCallback
, and memo
, remember that these shouldn’t necessarily be used by default. Apply these performance optimization only if you run into a performance bottlenecks. Most of the time this shouldn’t happen, because React’s rendering mechanism is pretty efficient by default. Sometimes the check for utilities like memo
can be more expensive than the re-rendering itself.