As I built my first project with React, one recurring issue was unnecessary renders. Sometimes, components would re-render even though there was no obvious trigger. Other times, the app would re-render infinitely. This was frustrating.
I cleared my frustration by learning everything I could about react rendering. This article is the culmination of that effort. The outline is as follows:
- What Triggers a Re-render in React
- Diffing and Reconciliation
- Parent, Child, and Expensive Components
- State as a snapshot
- Pure functions and side effects
React rendering has two phases: the render and commit phases. The ideas in this article cover the render phase. To fully appreciate it, you must have a good background in JavaScript and React.
What triggers a render in react?
The short answer? Changes in props and state. The long answer is as follows:
The document Object Model (DOM) is a tree-like structure that represents the HTML elements in a web page. The virtual DOM is React’s copy of the DOM. The virtual DOM represents JSX, State, and local variables of a React component.
A render is triggered when anything changes in the state.. React then creates a new virtual DOM. It also compares the new virtual DOM with the existing virtual DOM. Furthermore, it calculates the differences between both virtual DOMs. Finally, it uses the differences as a guide to update the real DOM. This is all part of the rendering process.
Diffing and Reconciliation
We established how changes in State trigger a re-render. But between the render trigger and updating the DOM, something important happens. React evaluates the new virtual DOM vs the existing virtual DOM to know exactly what needs to change.
How does this happen exactly?
When a render is triggered, React calls a render method on the components that changed. For a functional component, React will call FunctionComponenet(Props). But for a class-based component, reach will call classComponentInstance.render(). These render methods generate the output (JSX) for the component.
Next, the output is converted to a react element using React.CreateElement() method. Since react elements are plain JavaScript objects, the output becomes an object. We described this object tree earlier as the virtual DOM. Remember, DOM stands for Document Object Model, which means that it models an object. The code below explains this:
return React.createElement( 'button', { onClick: () => alert('Clicked!'), className: 'btn' }, 'Click Me'
); //becomes { type: 'button', props: { onClick: () => alert('Clicked!'), className: 'btn', children: 'Click Me' }
}
A new object is created when the render method creates the new virtual DOM. But the state of a React app at every moment is committed to memory. Thus, the existing virtual DOM still exists despite the creation of the new virtual DOM. This allows React to compare both objects and search for differences. The process of comparing those two objects is called diffing. The results from diffing form the basis of what React will change in the real DOM. The process of calculating those changes is called reconciliation.
Parent, Child, and Expensive Components
We have established how state changes trigger a react re-render. Yet, a component can re-render even when its own State has not changed. This happens because the re-rendering process always begins from the root component.
When a render is triggered, React will start searching all the components for changes. This will start from the root component, all the way down the tree. A component with a different state from the existing virtual DOM is then flagged. The flagged component – known as the ‘dirty component’ – then re-renders.
However, React’s default behavior is to render every child of a dirty component as well. So whether the child component has state changes of its own or not, it will re-render. This could cause issues, particularly if you have an expensive component on that tree. That is why you need to mitigate unnecessary re-renders.
Note: An expensive component is one with significant computational demands on your system.
Mitigating Unnecessary Re-Renders
To mitigate unnecessary re-renders, React provides several tools and techniques
React.memo:
This higher-order component prevents functional components from re-rendering if their props have not changed.
const MyComponent = React.memo((props) => { // Component logic
});
PureComponent:
For class components, extending React.PureComponent automatically implements a shallow prop and state comparison to prevent unnecessary re-renders.
class MyComponent extends React.PureComponent { render() { // Component logic }
}
useMemo:
Memoizes the result of a calculation or function to avoid recomputing it on every render.
const memoizedValue = React.useMemo(() => computeExpensiveValue(a, b), [a, b]);
useCallback:
Memoizes the function to prevent its recreation on every render, useful when passing functions to child components.
const memoizedCallback = React.useCallback(() => { doSomething(a, b);
}, [a, b]);
shouldComponentUpdate:
For fine-grained control, implement this lifecycle method in class components to determine whether a component should update.
class MyComponent extends React.Component { shouldComponentUpdate(nextProps, nextState) { // Return true or false based on custom logic }
}
Moving State Down:
Moving state down involves setting the state locally within the child component that requires it. This ensures that only the specific component needing the state will re-render when the state changes, rather than the entire parent component.
export default function App() { return ( <> <Form /> <ExpensiveTree /> </>
);
} function Form() { let [inputValue, setInputValue] = useState(''); return ( <> <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
<p>Input: {inputValue}</p>
</>
);
}
In this example, the color state is local to the Form component. Only the Form component re-renders when the color changes, leaving the ExpensiveTree component unaffected.
Moving Content Up:
If the state is needed in multiple components, you can move the content that doesn’t depend on the state up, so only the component with the state re-renders. This technique involves restructuring your components to isolate the state-dependent parts.
export default function App() { return ( <InputProvider> <DisplayMessage /> <ExpensiveTree /> </InputProvider>
);
} function InputProvider({ children }) { let [inputValue, setInputValue] = useState(''); return ( <div> <input value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
{children} </div>
);
} function DisplayMessage() { return <p>Some static content</p>;
}
In this example:
The ColorPicker component handles the color state and the elements dependent on it.
The App component contains the parts of the tree that don’t depend on the color state and passes them to ColorPicker as children.
This way, when the color state changes, only the ColorPicker component re-renders, and the ExpensiveTree component remains unaffected.
State as a snapshot
There are times when a function uses the value of a state variable. In cases like this, the function must always receive the latest version of the state variable. Otherwise, a stale value could lead to issues.
Stale values will be used in React when you use a state variable before it has been updated. This is possible because the state is not the state variable. The state may change, but the value itself takes a little time to change. This is due to a paradigm in React called ‘state as a snapshot’.
According to the React documentation, the state is a snapshot in time. The state of a component at every given time goes to a memory bank outside of the component. When the state changes, a new entry goes to that memory bank. Thus, there are two snapshots. The snapshot for the previous state, and the snapshot for the updated state.
However, the snapshot/state is a reference to the variable, not the variable itself. React still needs to update the state variable with the new value. And that does not happen until after a re-render. Thus, a re-render is not just a response to a change in state, it is also used to update the state variable.
import React, { useState } from 'react'; function StaleSateExample() { const [count, setCount] = useState(0); const [name, setName] = useState(''); const handleIncrement = () => { setCount(prevCount => prevCount + 1); // The `handleNameChange` function will use the stale value of `count` // because it captures the `count` value at the time it's defined handleNameChange(`Count: ${count}`); }; const handleNameChange = (newName) => { setName(newName); }; return ( <div> <p>Count: {count}</p>
<p>Name: {name}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
When we test this code, ‘name’ ought to be the same value as ‘count’. But see what happens instead:
That is because handlNameChange receives the old version of count, and not the updated one. You could fix it by doing the state update with a call call-back function. When you use a callback function as the argument for a state setter, react will ensure that it always receives the latest value.
import React, { useState } from "react"; export default function StaleSateExample() { const [count, setCount] = useState(0); const [name, setName] = useState(""); const handleIncrement = () => { setCount((prevCount) => { const newCount = prevCount + 1; const handleNameChange = (newName) => setName(newName); handleNameChange(`Count: ${newCount}`); return newCount; }); }; return ( <div> <p>Count: {count}</p>
<p>Name: {name}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
);
}
If you try it again, it works as expected.
Pure functions and side effects
React expects pure functions that have no side effects. Violating this expectation will lead to unexpected behavior.
A pure function is a formulaic function that given the same input will always return the same output. In mathematics, 2 + 2 is always 4. 3 x 3 is always 9. These arithmetic operations will not change. It is like a recipe. You will produce the same final product if you use the same ingredients and follow the cooking instructions precisely. Likewise, every function you intend to render in react must be pure.
Furthermore, your functions should have no side effects. A side effect happens when a function mutates or affects variables that come before it. The below code snippet is an example of a side effect.
let counter = 0; function Cup() { // Bad: changing a pre-existing variable! counter = counter + 1;
}
Conclusion
Rendering is an important part of react. Part of what made react revolutionary when it was first released was the idea of the virtual dom. It updated the DOM only with what had changed in the virtual DOM. Rendering is central to this whole paradigm. So understanding rendering will help you write better react.
Discover more from Coursity
Subscribe to get the latest posts sent to your email.