React Hooks - Understanding Component Re-renders
When I started working on React Hooks, the official documentation explained the core concepts quite well - motivation behind hooks, detailed api reference, rules of hooks, answering several frequently asked questions. But once I started using them, I felt a gap in my understanding around the component lifecycle. Sometimes I couldn’t explain why my component gets re-rendered (seems like a common question), what change caused an infinite re-render loop (another common one) and other times I wasn’t sure whether my usage of useCallback/useMemo is actually improving the performance.
Functional components using hooks will go through a lifecycle but without explicit lifecycle methods unlike their class based counterparts. While this non-explicit nature allows extracting re-usable behavior into custom hooks with much less boilerplate code, what I found myself lacking was the clear understanding of how hooks impact the execution flow.
This post is a result of my explorations to understand how each built-in hook influences the component re-renders/lifecycle. I’ll share my learnings with detailed examples and also highlight some pitfalls I ran into. This is a long post covering most of the built-in hooks with lot of screenshots and examples. If you are interested in only a specific hook, please go directly from the links below.
Table of Contents
Terminology
At a high level, React goes through three phases whenever it converts the component tree and flushes the result to the rendering environment:
(from this post by Kent C. Dodds):
* The “render” phase: create React elements
React.createElement
(learn more)
* The “reconciliation” phase: compare previous elements with the new ones (learn more)
* The “commit” phase: update the DOM (if needed).
Now, what prompts React to trigger the render phase? This StackOverflow’s post provides a succinct answer.
* Component receives new props.
* state is updated.
* Context value is updated (if the component listens to context change using useContext).
* Parent component re-renders due to any of the above reasons.
Every mention of ‘re-render’ in this post is about the “render phase” mentioned above.
About the examples in this post :
- We will implement a simple stock ticker component and add functionality around this component to understand the behavior of various built-in hooks. This component shows a ticker symbol and its price. The dropdown allows users to pick a different ticker.
- All examples show an execution log next to the component. A dashed border appears around the component whenever React re-renders the component (as a visual clue).
- The code samples are intentionally kept simple to mainly focus on the execution flow (no types; no backend calls; ticker quotes are mock data).
- The behavior in some of the examples may change when React releases concurrent mode.
useState
useState hook is the primary building block which enables functional components to hold state between re-renders.
Let’s understand the workings of useState with an example. We will implement the Ticker component as per the above sketch. The active ticker which is shown on the UI is stored in a local state using useState hook. Here is the relevant portion of the component (full code here).
const [ticker, setTicker] = useState("AAPL");
...
const onChange = event => {
setTicker(event.target.value);
}
...
Let’s understand this snippet in detail:
- The useState statement returns a state value (‘ticker’) and a setter function to update the corresponding state value (‘setTicker’).
- When the component is rendered for the first time the ‘ticker’ value would be the default value specified in the argument (i.e ‘AAPL’).
- When a new ticker is selected from dropdown, onChange function gets called. This function updates the state value using the setter function. This state change triggers a re-render — invoking the TickerComponent function to execute again. But this time “useState(‘AAPL’)” returns the ticker value which is previously set by the setter function. It is almost like React is storing the state in a hidden data-store tied to the instance of our component. The latest state value is held and returned across re-renders. If the component is unmounted and re-mounted then everything starts over.
All the above steps are visualized in the below screen-capture. When mounted, Ticker component is rendered with the default ticker (‘AAPL’). New ticker selections from the dropdown updates the ticker state which triggers a re-render. Notice the highlighted border right after the ticker selection. Execution log shows this step as ‘Begin Render’.
Here is the snapshot of the log:
This animation illustrates the component behavior from the context of the code (steps 1–12).
Impact of state updates in the parent component
We have seen the component re-render cycle due to the local state changes within the functional component. What would happen — when parent and child components have their own local state (via useState) and parent’s state gets changed ?
Let’s extend the TickerComponent to support a ‘theme’ prop. Its parent component(‘App’) holds the theme value in a state and passes it down to children via props.
(note: in real-world applications, useContext hook is more appropriate to support features like themes rather than passing values down through the component tree as props)
App component holds ‘theme’ variable in its state and has two Ticker Components as children. Theme is passed to the TickerComponents as props. TickerComponent consumes the props.theme and renders the value as css class (this is the only change to the TickerComponent from the previous example).
Let’s parse the execution log —
- 1st group: When App component got mounted, it is initialized with the default ‘dark’ theme and the ticker components were initialized with default ‘AAPL’ ticker.
- 2nd/3rd group: New tickers are selected in both the Ticker components and the state changes triggered local re-renders (like earlier example)
- 4th group: Now the theme value is changed in the parent, both the parent (App) component and its children (ticker components) were re-rendered.
When the App component re-renders, its children would re-render irrespective of whether they consume the theme value (as a prop) are not. Checkout this updated example in which the second ticker component doesn’t consume theme state but gets re-rendered when the theme is changed. This is React’s default behavior and it can be altered by wrapping the child components with the useMemo hook which we’ll look into it shortly.
Additional notes:
- When a state variable is initialized using some prop value as default (snippet below), the prop value is used only the first time when the state is created. Any further updates to the prop won’t automatically ‘sync’ the local state of the component. Such an assumption will result into a buggy behavior, check out Dan’s excellent post on this topic, and this post from React docs for detailed explanation of this anti-pattern and probable solutions.
const [localState, setLocalState] = useState(props.theme);
- When setState handler is called multiple times, React batches these calls and triggers re-render only once when the calling code is inside React based event handlers. If these calls are made from non-React based handlers like setTimeout, each call will trigger a re-render. (refer to this post on this topic)
useEffect
From the docs - Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.
Instead, use
useEffect
. The function passed touseEffect
will run after the render is committed to the screen. Think of effects as an escape hatch from React’s purely functional world into the imperative world.If you’re familiar with React class lifecycle methods, you can think of
useEffect
Hook ascomponentDidMount
,componentDidUpdate
, andcomponentWillUnmount
combined.
Let’s make sense of all these statements using couple of examples.
In the first example — We’ll simply add useEffect handler to the TickerComponent with some log statements. The goal is to understand when this function gets executed in the component lifecycle.
useEffect(() => { // logs 'useEffect inside handlers
// logs the active ticker currently shown (by querying the DOM) return () => {
// logs 'useEffect cleanup handler'
};});
Below is the simplified code (full version is here). The execution log is a result of three actions:
- Mount component.
- Change ticker to ‘MSFT’.
- Unmount component.
Let’s look at the execution log closely to understand what’s happening -
#1, 2, 3: Upon mounting, Component is rendered with default ticker (‘AAPL’).
#4: useEffect handler ran the first time after the mount — DOM element already had the default state (‘AAPL’). This means by the time useEffect handler runs, React finishes syncing the component state to DOM. Note: cleanup handler didn’t run yet (since no useEffect handler calls ran earlier).
#5: New ticker is selected from the dropdown (to ‘MSFT’).
#6, 7, 8: State changed. Component re-rendered with new state (‘MSFT’).
#9: Cleanup handler returned from the previous useEffect call ran now (note: ticker value here is ‘AAPL’ because this closure was created and returned from the previous useEffect run).
#10: useEffect handler runs again and just like before DOM already has the new ticker state (‘MSFT’).
# 11, 12: Lastly, when the component is un-mounted, React makes sure to run the cleanup handler related to the last effect (for ticker ‘MSFT’).
We learned two things from this example:
- React runs useEffect handler after it fully synchronizes the current component state to DOM. This makes it an ideal place to initiate the side-effects (backend calls, registering subscriptions, logging, timers etc).
- The function returned by the useEffect handler (cleanup handler) runs just before the useEffect handler runs the next time (or after unmount). This is an ideal place to do the cleanup operations related to the side-effects (un-subscriptions/detaching-event-handlers etc). This is also important to avoid memory leaks. Since the ‘clean up’ handler is a closure, it captures the state when the function is created and returned, things will work naturally even that function gets executed in the next re-render (checkout steps #9 and #12 from the logs — the state value in the cleanup handler is from the earlier iteration).
Let’s tie all these concepts with a concrete example. We want to update the ticker component such that it will continuously refresh the active ticker’s latest price quote. We will use a (mock) streaming stock quote service. The API allows to register a callback for a ticker which gets called every few seconds with the current price quote. It returns a cleanup handler, when called the API stops executing the callback for the ticker.
Below is the condensed code snippet (full version is here). Every price change notification does a setState on price which triggers a re-render. When the active ticker is changed, you can see the cleanup handler runs for the previous ticker.
Let me briefly go over the dependency array (2nd param) in the useEffect call. When this parameter is omitted, React will execute useEffect handler after every re-render (like the first example in useEffect). This will be inefficient most of the times. When the dependencies are specified, React will run the useEffect handler only when any of the dependencies in that list changes.
Most of the times infinite re-renders are a result of NOT properly configuring the dependency list. For e.g. if you add a function reference as a dependency and if the reference changes with every re-render (function gets re-created) then useEffect runs with every re-render and a state change within this handler causes a re-render and the cycle repeats causing an infinite render loop.
Few interesting things to note from the snapshot:
- #4, #16: useEffect handler (which registers for the price updates) ran only when the ticker state is changed. useEffect (and cleanup) handlers didn’t run with every re-render because of the dependency list.
- Every price change notification (#5, #8, #17, #20) triggered a re-render just like the ticker change event (#11, #12). Both these handlers update corresponding state values using useState.
- Cleanup handler for the default ticker ‘AAPL’ ran (#15) just before the useEffect handler runs for the new ticker state ‘MSFT’ (#16).
Additional References:
- Checkout Dan’s ‘Don’t Stop the Data Flow in Side Effects’ post to appreciate the elegance of the useEffect hook.
useLayoutEffect
Sometimes the side-effects we would like to apply are directly manipulating with the DOM (css updates based on the rendered layout, drag & drop etc). In such cases ‘useLayoutEffect’ handler is the ideal place to do those mutations.
From the docs - The signature is identical to
useEffect
, but it fires synchronously after all DOM mutations. Use this to read layout from the DOM and synchronously re-render. Updates scheduled insideuseLayoutEffect
will be flushed synchronously, before the browser has a chance to paint.
Let’s see what it means with a tailored example. This time we would like to build a quote component — It has a button to fetch a random quote and a display surface to render that quote. We want to keep the display surface at fixed height irrespective of the size of the quote. When a longer quote is retrieved, the content should be clipped and ‘expand full quote’ link should appear. Here is the sketch of this setup-
Before using useLayoutEffect handler, let’s continue with the known useEffect handler and see why that doesn’t work in this scenario. Below is the condensed code snippet which clips the container when long quote is rendered — It checks the container height and adds a ‘fixed-height’ css class when the original height crosses a threshold. This code runs in the useEffect hook handler. The screen capture on the right shows a blink like behavior when a long quote is displayed.
Chrome performance profiler reveals why this happens (screenshot below) —
- When long quote is set to the state, React commits the the full quote to the DOM.
- Browser does the layout calculation and paints the full quote on the screen (refer to the label ‘Browser painted full long quote here’, green blocks refer to the paint operation).
- useEffect handler runs now. We can spot its execution based on the custom perf marker we added in the code (‘use_effect_handler_end’). This marker shows up after the first paint.
- In the useEffect handler, a css class has been added on the container to clip the height. This change forces the browser to re-run the layout and paint operations. Refer to the label — ‘Browser applied the overflow css and repainted the screen’.
- This (slight) delay between these two paint operations is causing the jarring experience.
This behavior would simply go away when ‘useEffect’ is replaced with ‘useLayoutEffect’ with no other changes (full code here). Here is the updated performance profile. The position of the ‘use_layout_effect_handler’ marker (in the timings tab) indicates the execution of the useLayoutEffect handler code. It ran synchronously right after the commit state which resulted in one paint operation with the ‘clipped’ quote (our ideal final state).
useContext
Context provides a way to pass data through the component tree without having to pass props down manually at every level.
All consumers that are descendants of a Provider will re-render whenever the Provider’s
value
prop changes. The propagation from Provider to its descendant consumers is not subject to theshouldComponentUpdate
method, so the consumer is updated even when an ancestor component bails out of the update.
Let’s understand all of this with examples. If you recall the theme selection example we did earlier with useState. Let’s rewrite that using Context and useContext and see how the context updates trigger the re-renders.
We will make one change to the UI — We will make it such that only the first (Themed)TickerComponent supports theme (dark/light modes). The second TickerComponent always renders in Dark mode. This enables us to see the impact on components that consume useContext vs the ones that don’t.
Here is the hierarchy of the components and their code snippets including the execution log.
The execution log has no surprises, when theme selection is changed, only the ThemedTickerComponent and it’s child TickerComponent (1) are re-rendered since it is a consumer of the themeContext. There are no logs for TickerComponent2.
// ThemedTickerComponent
const { theme } = useContext(ThemeContext);
Inefficient consumption of useContext
Let me slightly rearrange the component hierarchy from the above example to show an inefficient use of useContext. I deleted the ThemeSelector component and and moved all its code right into its parent (App) component.
Now with this setup, every theme change re-renders the App component and all of its children irrespective of whether they consumed the theme or not. Unlike the last example, TickerComponent(2) is also re-rendered in this case.
This is because App component now became the consumer of the useContext.
If the context value changes too often and a component somewhere high in the tree consumes useContext will cause a re-render of all its children (unless they are memoized). A situation like this may result into a performance bottleneck. As always, measure the perf-impact before optimizing it. React dev tools profiler should come-in handy.
useCallback & useMemo
useCallback — Returns a memoized callback.
Pass an inline callback and an array of dependencies.
useCallback
will return a memoized version of the callback that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders (e.g.shouldComponentUpdate
).useMemo — Returns a memoized value.
Pass a “create” function and an array of dependencies.
useMemo
will only recompute the memoized value when one of the dependencies has changed. This optimization helps to avoid expensive calculations on every render.
Both these hooks come in handy when building performance sensitive components. We will work on another tailored example to effectively understand the usage of these hooks. We will build a Stocks Watchlist Component as shown in the sketch below. We should build this component such that deleting a ticker shouldn’t re-render the remaining tickers (which is a reasonable expectation when rendering large list of items).
I outlined the solution in a series of incremental steps aligning with my original thought-process. It also highlights some of pitfalls that we will run into.
step#1 — Start with default implementation — no useCallback/useMemo
Snippet of the Watchlist Component is shown below -
- this component stores a list of tickers (in state) and defines an onRemove handler which updates the state by removing the ticker sent as param.
- It renders the TickerComponent for each ticker in the list by passing the ticker value and onRemove handler as props.
As seen in the screen capture, every time a ticker gets deleted all the other tickers are getting re-rendered. When a ticker is being removed, the watchlist state gets updated forcing the Watchlist component its children to re-render. We have seen this default behavior in the earlier useState example.
step#2 — Leverage useMemo and useCallback
We don’t want the TickerComponent to re-render every-time its parent gets re-rendered unless the dependencies are changed. useMemo hook provides the memoization functionality we are looking for.
This code-snippet shows the memoized TickerComponent leveraging the useMemo hook. React skips re-rendering the component and returns the previously cached result unless one of the listed dependencies change (ticker, onRemove handler).
Next, we need to optimize the ‘onRemove’ prop. It is defined in the watchlist component as an anonymous function which gets re-created every time whenever the Watchlist component re-renders. Since its reference changes with every re-render, it simply nullifies the TickerComponent memoization we made above.
const onRemove = tickerToRemove => {
setWatchlist(
watchlist.filter(ticker => ticker !== tickerToRemove)
);};
We don’t want a new function reference with every re-render. useCallback hook is what we are looking for. It takes a function and returns a memoized function whose reference won’t change between re-renders unless one of its dependencies change. Let’s wrap the onRemove handler with useCallback.
Here is the log after making both the changes. Interestingly, both these changes didn’t stop re-rendering the existing ticker components when a ticker is removed. You can see ‘Begin Render’ from the existing ticker components.
Final step — use functional form of setState
If you closely look at the useCallback wrapped onRemove handler, watchlist is added to the dependency array since the function updates the watchlist. This turns out to be the reason for the re-renders.
Whenever a ticker is removed:
* watchlist state gets updated with a new array reference after filtering out the removed ticker.
* in the next re-render useCallback returns a new onRemove handler reference since its dependency (watchlist) is changed.
* new onRemove handler reference will nullify the memoization of the TickerComponents.
We are back to the same issue as with the default implementation but now with more memory footprint with all the memoizations (!!!)
To achieve what we need- onRemove function reference shouldn’t change even when the watchlist array changes. We somehow need to create this function once and memoize it without watchlist as a dependency.
Thankfully, setter function from useState hook supports a functional variant which comes to our rescue. Instead of calling setWatchlist with the updated watchlist array, we can instead send a function that gets the current state as an argument. Here is the modified version of the onRemove handler without a dependency on the watchlist array. The inner function gets the current watchlist (state) as an argument and it filters out the ticker to remove and returns the updated watchlist array. watchlist state gets updated with the returned value.
This brings the needed optimization we are looking for. Now deleting a ticker won’t re-render the existing ticker components (no flashing borders around the ticker components).
As you may have noticed, it is easy to add useCallback and useMemo hooks though out the codebase without the intended perf benefit. Adding them efficiently requires thorough understanding of the component hierarchy and a proper way to measure the performance gains.
Be sure to checkout this excellent post from Kent C. Dodds thoroughly covering this topic. https://kentcdodds.com/blog/usememo-and-usecallback
useReducer
From the official docs, An alternative to
useState
. Accepts a reducer of type(state, action) => newState
, and returns the current state paired with adispatch
method. (If you’re familiar with Redux, you already know how this works.)
useReducer
is usually preferable touseState
when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one.useReducer
also lets you optimize performance for components that trigger deep updates because you can passdispatch
down instead of callbacks.
Watchlist component from the above example has been modified to use useReducer. This approach makes things much simpler and won’t run into the state invalidation issues we had with useCallback in the previous example.
These examples clarified lot of my questions around the execution flow/re-renders. They also helped in creating a better mental model around building functional components using hooks more efficiently.
Thank you for taking time and reading this far. I hope you find this post useful. If you have any feedback, please do let me know in the comments.
Credits:
Many thanks to Shyam Arjarapu, Dmitry Ryzhkov, Adam Carr and Pierre Andrews for reviewing this article and providing valuable feedback.
Thanks to the authors of these amazing tools — codesandbox (for code snippets) and zwibbler (for sketches).