Matthew W Buckley

Rendering in React

March 12, 2024
react

React can make applications incredibly reactive, but with that comes a challenge—React is a glutton for recalculation and re-renders. Every time React detects a difference in props or state, React may re-render components, sometimes unnecessarily. While React's reconciliation process is optimised to minimise updates, this often is not enough. Writing performant React code isn't just about making things reactive; it's about working with React to help it avoid its gluttonous impulse to re-render everything.

The Basics

So, let's start simple. We've got a single component with a single state variable called count. Press the button to increase the count, and the component will re-render. To make things more fun, I've added some sounds - boops for state changes and clicks for renders.

Counter Component
count:0

You probably noticed some things flashing blue. The count flashed because its value has changed. That caused the CounterComponent to flash too - because it re-rendered.

Cool. Now, here's a question you might not expect: what happens when we add children?

The Inevitable Complexity of Children

Children are everywhere. In React, even more so. So, let's teach one of our children to count.

Counter Component
Child
Child
Counter Component
count:0

Nice and simple - state changes in the child, the child re-renders. But things get more complicated when it's the parent that needs to update.

The Parent Trap

Here's a pretty common pattern, at least in my codebases - nested components, each importing and using another until we reach the last child.

const Grandchild: React.FC = () => {
	return (
		<div />
	);
};

const Child: React.FC = () => {
	return (
		<div>
			<Grandchild />
		</div>
	);
};

const Parent = () => {
	const [count, setCount] = useState(0);

	return (
		<div>
			<ValueBadge label="count" value={count} />
			<Button onClick={() => setCount(c => c + 1)}>
				Increment to trigger re-render
			</Button>
			<Child />
		</div>
	);
};
Counter Component
count:0
Child
Grandchild

And there it is—everything re-renders. Now, imagine these little components are part of a larger, more mature codebase, packed with state, props, API calls, context, hooks, effects… probably not great for performance.

You may also have noticed that the children render after the parent. It doesnt! Thats just something I did for the visuals. React functions are called from the root to the last child, but they only return once their child has returned. I think of it as being called top to bottom, but rendering bottom to top.

So, React has given us a wonderfully reactive app where everything updates. Now… how do we stop that?

Reconciliation of React Components

What do you get from calling a React function? Well, at some point between it being called and being on screen you get an Object. That object is derived from the JSX returned from your component. If you would like to learn more about how JSX relates to the object returned from a React Component, have a look at the first Kent C Dodds article I will be linking [1]. And when we talk about reconciliation we are talking about React comparing each level of this complex object to what it was on the previous render. Have a look at these articles if you are interested how reconciliation happens [2] and how it can result in unexpected behaviour [3]. They also talk about React key prop, which I don't want to talk about today.

So given that we Anyway, let's look at another example and contrast it to how we earlier saw a parent updating and causing the re-rendering of our children. This time, I've pulled out the dynamic content into a separate component that takes in the children prop.

const Grandchild: React.FC = () => {
	return (
		<div />
	);
};

const Child: React.FC = ({children}) => {
	return (
		<div>
			{children}
		</div>
	);
};

const Counter = ({children}) => {
	const [count, setCount] = useState(0);

	return (
		<div>
			<ValueBadge label="count" value={count} />
			<Button onClick={() => setCount(c => c + 1)}>
				Increment to trigger re-render
			</Button>
			{children}
		</div>
	);
};

const Parent = () => {
	return (
		<Counter>
			<Child>
				<Grandchild />
			</Child>
		</Counter>
	)
}

Parent
Counter Component
count:0
Child
Grandchild

I find this very interesting, and I am going to call on Kent C Dobbs again to explain whats happening here in more detail [4], but I will give you a short explanation. When React is judging what needs to re-render it looks at a few things including resulting element type, props, state and, importantly, the update or render path. For both examples the children do not change type, props, or state. The difference is the render path, because in the example where we extract the Counter component the Child and Grandchild components are actually children of the Parent, which does not update. Neither Child nor Grandchild have a Parent that updated.

This can be a very useful property of React, and is especially useful when using Reacts Context, which is often defined high up in Reacts tree so that it can be accessed from anywhere in the app. Lets have a look at a quick Context Example which has a Provider at the top, and two Consumers lower down in the tree. One Consumer will update the value, causing a re-render and the other Consumes the value value to display it.

I hope you don't mind me skipping the code for this one. Its pretty much the same as when we extracted the Counter, but with Context.

Parent
Context Provider
count:0
Child
Count Display
Count: 0
Child
Child
Count Button

Notice how the Child components do not re-render. We could even improve this more, because the component that increments the function does not need to update, but at the moment it consumes all of the Context, including the updated count value .

So we have seen how Reconciliation can be used to stop re-renders if we have no changes to element type, props and state, and no parent which has updated. However, if we know that a component has not changed, despite having a parent that has, is there any way that we can tell React to skip the re-render?

Yeah.

Memoising Components

Lets revisit something that we did early on in this post, a parent with children. We know that the count is not passed to the children and because nothing has changed for them they do not need to re-render. We can stop the re-rendering by wrapping the component in React.memo().

const MemoisedGrandchildComponent = React.memo(() => {
	return <RenderVisualizer label="Memoised Component" level={2}><div/></RenderVisualizer>;
});

const MemoisedChildComponent = React.memo(() => {
	return <RenderVisualizer label="Memoised Component" level={1}><MemoisedGrandchildComponent /></RenderVisualizer>;
});


const ParentComponent = () => {
	const [count, setCount] = useState(0);
	
	return (
		<div>
			<ValueBadge label="count" value={count} />
			<Button onClick={() => setCount(c => c + 1)}>Increment Count</Button>
			<MemoisedChildComponent />
		</div>
	);
};
Parent
Memoised Component
Memoised Component

Nice. We have a parent that updates and children that don't, because they are the same. Here is an interesting question: If the Grandchild was not wrapped in React.memo(), would it re-render? How about if that Grandchild has access to context which did update?

Well, if Grandchild is not memoised, then it would not re-render. React.memo() actually stops the function from being called again, and just returns its previous value. But then you may expect that since Grandchild is not called then changes to Context would not show? But actually, if there was a change in Context that affected Grandchild then it would re-render. This is because React tracks all components which subscribe to context and mark them as requiring update if that context changes.

The Problem of Equality

You've probably heard of the thought experiment The Ship of Theseus. If every component of a ship is replaced over time, is it still the same ship?

Well, Javascript and React ask a similar question:

If you have two ships with the same components, are they the same ship?

And if you know how JavaScript handles object equality [5], you know the answer is… no. Objects are compared by reference in JavaScript and not by what values they contain. And that means that a lot of equality checks will fail. At least, they will fail without some help.

Before I go on, consider reading through Josh W Comeau's explanation of memoisation and callbacks [6]. Just Please com back.

Memoisation of Values

Memoisation is a way to control when a value updates. It does this by defining when a value should update by defining its dependencies.

const memoValue = useMemo(()=>x+x), [x])

Now this is important for preventing unnecessarily re-calculation, especially when values are costly to compute. However, this is more about rendering in react, and it fits in here too. Memoisation makes a referentially compared value (objects) stable between re-renders.

This is important when passing values as props to components, as otherwise these props will be considered to have changed and you will get a lot of needless re-renders. In this example we are passing props to three different components that are memoised.

const MemoisedComponent = React.memo(({ value, label }) => {
	return <RenderVisualizer label="Child" level={2}>
		<ValueBadge label={label} value={simpleValue} />
	</RenderVisualizer>
});  

const MemoisationExample: React.FC = () => {
	const [count, setCount] = useState(0);
	
	const simpleValue = "Hello";
	
	const someObjectData = {
		name: "Kermit",
		occupation: "Frog"
	}

	const someMemoisedValue = useMemo(() => {
		return {
			name: "Gonzo",
			occupation: "Unknown"
		};
	}, []);

	return (
	<RenderVisualizer label="Parent">
		<Button onClick={() => setCount(c => c + 1)}>
			Trigger re-render
		</Button>
		<MemoisedComponent value={simpleValue} label="Primative Value"/>
		<MemoisedComponent value={someObjectData} label="Object Value"/>
		<MemoisedComponent value={someMemoisedValue} label="Memoised Object Value"/>
	</RenderVisualizer>);
};
Parent
Child
Primative Value:Hello
Child
Object Value:{"name":"Kermit","occupation":"Frog"}
Child
Memoised Object Value:{"name":"Gonzo","occupation":"Unknown"}

The upper component is passed a string that doesn't change, its compared by value, and the memoisation kicks in meaning the component doesnt re-render. In the middle component an object is passed. The object is not memoised, and so is not referentially stable between renders, and so the component re-renders. In the lowest component we have memoised the object, its stable between re-renders and so the memoised component doesnt need to re-render.

So thats pretty cool. So if you want to cut down re-renders there need to be no changes (compared via value or reference) to a memoised component. However, if you are passing a function then instead of memoising the function, it needs to be converted into a callback.

The Callback

In practice this is basically the same as memoisation. In fact, you could use useMemo if you want.

const MemoisedComponent = React.memo(({ value, label }) => {
	return <RenderVisualizer label="Child" level={2}>
		<ValueBadge label={label} value={simpleValue} />
	</RenderVisualizer>
});  

const MemoisationExample: React.FC = () => {
	const [count, setCount] = useState(0);
	
	const rawFunction = () => {
		console.log("Raw function called");
	}

    const memoisedFunction = useMemo(() => {
		return () => console.log("Raw function called");
	}, []);
	
	const callbackFunction = useCallback(() => {
		console.log("Callback function called");
	}, []);

	return (
	<RenderVisualizer label="Parent">
		<Button onClick={() => setCount(c => c + 1)}>
			Trigger re-render
		</Button>
		<MemoisedComponent value={rawFunction} label="raw function"/>
		<MemoisedComponent value={memoisedFunction} label="memoised function"/>
		<MemoisedComponent value={callbackFunction} label="callback function"/>
	</RenderVisualizer>);
};
Parent
Child
Raw Function:()=>{console.log("Raw function called")}
Child
Memoised Function:()=>console.log("Raw function called")
Child
Callback Function:()=>{console.log("Callback function called")}

There we go. Time for some conclusions.

Conclusions

React is bit too reactive, and we have tools for reigning it in. We can structure our react to extract dynamic elements and allow Reacts reconciliation to do its thing. We can also wrap components in React.memo() and memoise values and functions that we pass as props.

Some of you may be thinking; thats all well and good, but did you forget about the React compiler that means all this manual render optimisation is going to be (if not already is) no longer required? And yeah... there is that. Its still interesting though, plus its pretty easy to write.

Well anyway, thanks for sticking with it.

References

  1. https://kentcdodds.com/blog/what-is-jsx
  2. https://www.dhiwise.com/post/a-deep-dive-into-react-reconciliation-algorithm
  3. https://www.developerway.com/posts/reconciliation-in-react
  4. https://kentcdodds.com/blog/optimize-react-re-renders
  5. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Equality_comparisons_and_sameness
  6. https://www.joshwcomeau.com/react/usememo-and-usecallback/

Some Further Reading