
React Rendering under the hood
Note: This article has been updated (in March 2025) to reflect changes in recent React versions (including React 18) regarding Concurrent Features and Automatic Batching and the React Compiler (of React 19)
This article describes in detail how React rendering works under-the-hood, how to optimize re-renders, and explains how the use of Context and Redux affects Reactâs rendering process.
What is âRenderâ?
Rendering is the process of React asking your components to describe what they want their section of the UI to look like, now, based on the current combination of props and state.
Render is a process where React asks components to return a description of the UI elements within that component, based on the combination of props and state.
Overview of the Rendering Process
During the rendering process, React starts with the root component tree and iterates down through the child components to find those that have been marked as needing an update. For each marked component, React runs classComponentInstance.render()
(for class components) or runs FunctionComponent()
(for functional components) to get the output of the rendering process.
The render output of a component is often written in JSX, which during the build (compile) process is converted into React.createElement()
calls. createElement
returns React elements (also known as âVirtual DOMâ), in the form of plain JS Objects - providing a description of the UI Componentâs structure. For example:
// This is JSX:
return <SomeComponent a={42} b="testing">Text here</SomeComponent>
// After build, it will be converted to:
return React.createElement(SomeComponent, {a: 42, b: "testing"}, "Text Here")
// And when the browser executes the compiled code, it will create a React element object like this:
{type: SomeComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
After collecting enough render output from the component tree (resulting in a React element object), React compares (diffs) the new virtual DOM with the current virtual DOM, obtaining a set of actual changes that need to be updated in the real DOM. This comparison and calculation process is called âreconciliationâ.
React then applies all the calculated changes to the real DOM tree in a synchronous order (Render Phase and Commit Phases).
Render Phase and Commit Phases
The React team divides the UI update process into two main phases:
- Render Phase: In this phase, React calls the render function of the components that need to be updated (including class
render()
and functional component body), creating a new Virtual DOM tree describing the desired UI, and compares (diffing) it with the previous Virtual DOM tree to determine the necessary changes. This phase can be paused, canceled, or re-executed by React in Concurrent Mode to prioritize more important tasks. - Commit Phase: After calculating the changes in the Render Phase, React applies those changes to the browserâs real DOM tree. This phase occurs synchronously and cannot be interrupted. Immediately after the DOM is updated, React runs
componentDidMount
,componentDidUpdate
(for class components) anduseLayoutEffect
hooks.
After the Commit Phase is complete and the browser has painted the changes to the screen, React runs the useEffect
hooks. This useEffect
running phase is sometimes called the âPassive Effectsâ phase. Delaying the execution of useEffect
until after the browser paints ensures that the logic inside the hook (e.g., fetching data) does not block the initial UI rendering, making the application feel smoother.
You can view a visualization of the class lifecycle methods here.
In recent React versions with Concurrent Features, React has the ability to pause work during the âRender Phaseâ to allow the browser to handle events. React will continue, cancel, or recalculate that work later depending on the situation. Once the rendering process is complete, React will still run the âCommit Phaseâ synchronously in a single step.
The focus of this section is to understand that ârenderingâ is not âupdating the DOMâ. A component can be rendered without any changes being made to the real DOM. When React renders a component:
- If the component returns the same render output as the previous render, no changes need to be applied (to the real DOM) -> no commit at all.
- In Concurrent Mode, React might end up rendering a component multiple times, but throw away the render output each time if other updates invalidate the current work being done
How does React handle Renders?
Queuing Renders
After the initial render is complete, there are several ways to trigger React to render on a few components (marking that component as needing an update, and React will then perform the re-rendering process):
- Class components:
this.setState()
this.forceUpdate()
- Functional components:
useState
settersuseReducer
dispatches
- Other:
- Calling
ReactDOM.render(<App/>)
again, equivalent to callingforceUpdate()
at the root component. - Using
flushSync
to force React to perform state updates synchronously, escaping the automatic batching mechanism.
- Calling
Standard Render Behavior
An important thing to remember:
Reactâs default behavior is that when a parent component renders, React will recursively iterate and render all of that componentâs child components!
For example, suppose we have a component tree A > B > C > D
, and we have finished the initial render (UI is already showing). Then, the user clicks a button in B
- increasing a counter variable in component B
:
- We call
setState()
inB
, marking B as needing an update - React starts running render from the top of the component tree
- React sees that
A
is not marked as needing an update, so it skips it - React sees that
B
is marked as needing an update and runs the render function ofB
.B
returnsC
. C
is not marked as needing an update. However, since its parent componentB
has just re-rendered, React will re-render the child componentC
.C
returnsD
.D
is similar to the above, although not marked as needing an update, becauseC
re-rendered, React also performs a re-render ofD
.
Once again:
Rendering a component will, by default, cause all the components inside it to re-render as well!
Another note:
During normal rendering, React does not care about âprops changedâ - it will re-render all child components unconditionally just because their parent component is re-rendering.
This means that calling setState()
in the root <App>
component will cause all components in the App to re-render.
It is very likely that most of the components in the component tree will return exactly the same render output as the previous time, and therefore React does not need to update anything on the real DOM. But, React will still have to do the work of running the render function on each component, wait for the render output, and compare this render output with the render output of the previous time - these things will take time and CPU processing power.
Component Types and Reconciliation
As described in the âReconciliationâ docs page, Reactâs render logic compares elements based on the type
field first, using the ===
comparison. If an element in a position changes to a different type, such as from <div>
to <span>
or from <ComponentA>
to <ComponentB>
, React will assume that the entire subtree below that component has changed completely. As a result, React will discard all current component render output, including all DOM nodes (real DOM), and recreate it from scratch with a new component instance instead of trying to compare the subtrees in detail.
This means that you should never create a new component type in the render()
function (or in the function body of a functional component), because when you create a new component type, it has a new reference (because it is an object), which will cause React to continuously delete and recreate the entire component after each render.
In other words, donât do this:
function ParentComponent() {
// This line will create a new ChildComponent reference every time it renders!
function ChildComponent() {}
return <ChildComponent />
}
Instead, always define the component separately:
// This line will only create 1 component type
function ChildComponent() {}
function ParentComponent() {
return <ChildComponent />
}
Improving Render Performance
As mentioned above, Reactâs rendering process can be redundant and time/resource-consuming each time it runs. If a componentâs render output is unchanged, and no updates are needed to the real DOM, then the rendering process is truly wasteful and unnecessary.
React component render output differs based on whether the current props and component state have changed. Therefore, if we know in advance that a componentâs props and state will not change, we will also know that the render output after the componentâs render will be exactly the same as before, and no changes need to be applied, and we can skip running re-render on that component.
When trying to improve software performance in general, there are two basic approaches:
- Make the system run a task faster (1)
- Make the system have to run fewer tasks (2)
Optimizing React Rendering is primarily about trying to skip unnecessary re-renders (2).
Render Batching and Timing
By default, each call to setState()
causes React to start a new rendering process, synchronously, and return. However, React also applies a type of automatic optimization, called ârender batchingâ. Render batching is when React groups multiple state updates that occur close together into a single re-render to improve performance.
The React docs mention that âstate updates may be asyncronousâ, largely due to this Render Batching mechanism. Before React 18, React only automatically batched state updates that occurred within React event handlers.
However, since React 18, React by default performs Automatic Batching for all state updates, regardless of where they are triggered from (in Promises, setTimeout
, native event handlers, or any other source). This helps reduce the number of unnecessary re-renders significantly.
React implements render batching for event handlers by wrapping them in an internal function called unstable_batchedUpdates
. React tracks all state updates called (calling setState()
, âŠ) while unstable_batchedUpdates
is running, and then applies them in a single render.
Conceptually, you can imagine what React is doing internally as the following pseudocode:
function internalHandleEvent(e) {
const userProvidedEventHandler = findEventHandler(e);
let batchedUpdates = [];
unstable_batchedUpdates(() => {
// every state update called here will be pushed into batchedUpdates
userProvidedEventHandler(e);
});
renderWithQueuedStateUpdates(batchedUpdates);
}
However, this means that all state updates that are outside the immediate call stack of that event handler will not be batched by React.
Take the following example (with React < 18):
const [counter, setCounter] = useState(0);
const onClick = async () => {
setCounter(0);
setCounter(1);
const data = await fetchSomeData();
setCounter(2);
setCounter(3);
}
In React < 18, this code will perform 2 renders. The first time, setCounter(0)
and setCounter(1)
are batched because they are inside the event handler. The second time, setCounter(2)
and setCounter(3)
are in the callback of fetchSomeData
(assuming itâs a Promise), they will not be batched and each call will trigger a separate re-render (a total of 2 re-renders for the async part).
With React 18 and later, thanks to Automatic Batching, all 4 calls to setCounter
in the above example will be grouped together and only cause a single re-render.
If you really need to escape the batching mechanism and want React to update the DOM immediately after a specific state update, you can use ReactDOM.flushSync()
.
import { flushSync } from 'react-dom';
// ...
const onClick = () => {
flushSync(() => {
setCounter(prev => prev + 1);
});
// Immediately after the line above, the DOM has been updated with the new counter
flushSync(() => {
setOtherState(prev => prev + 1);
});
// The DOM is updated again immediately
}
Note: Use
flushSync
sparingly as it can affect performance.
Another note is that React will double-render components inside the <StrictMode>
tag in development mode, so you should not rely on console.log()
to count the number of times a component re-renders. Instead, use the React DevTools Profiler to capture tracing, or add a logging to the useEffect
hook or componentDidMount/Update
lifecycle - that log will only be printed when React actually finishes rendering and commits changes to the real DOM.
Optimization Techniques for Component Render
React provides us with 3 APIs to allow skipping the re-rendering process on a component:
React.Component.shouldComponentUpdate
: is an optional class component lifecycle method that will be called before the render process takes place. If this method returnsfalse
, React will skip re-rendering the component. A common use of this method is to check if the componentâs props and state have changed.React.PureComponent
: this is a Base Class that replacesReact.Component
, implementing theshouldComponentUpdate
function by performing a shallow comparison of new and old props and state.React.memo()
is a built-in âhigher order componentâ. It takes a component as a parameter and returns a wrapper component. The default behavior of this wrapper component is to check if the props have changed (using shallow comparison), and if not, prevent re-rendering. Both functional components and class components can be wrapped byReact.memo()
.React.memo
only compares props, not state.
All of the above approaches use a comparison technique called âshallow equalityâ (shallow comparison). This means that it will check all the individual fields in the 2 objects to see if they have the same value (using ===
). In other words, obj1.a === obj2.a && object1.b === object2.b && ...
.
There is also a lesser-known optimization technique in React: if a React component returns a render output that is the same element reference as the previous time, React will skip re-rendering.
For all of these techniques, skipping re-rendering a component means that React will also skip rendering on the entire subtree element of that component (ârender children recursivelyâ behavior).
How using References for new Props affects Render Optimization
We all know that by default, React re-renders all nested components even if their props havenât changed. This also means that passing props as new references to a child component is okay, because it will also re-render whether you pass the same props or not. For example, the code below is fine:
function ParentComponent() {
const onClick = () => {
console.log("Button clicked")
}
const data = {a: 1, b: 2}
return <NormalChildComponent onClick={onClick} data={data} />
}
Each time ParentComponent
re-renders, it will create a new onClick
function reference and a new data
object reference, then pass them as props to NormalChildComponent
. (Note that defining the onClick
function using the function
keyword or using an arrow function
makes no difference in this case).
That also means that there is no point in trying to optimize âbasic componentsâ, like a <div>
or a <button>
, by wrapping them in React.memo()
. There are no child components below those basic components, so the re-rendering process will stop there in any case.
Optimizing Props Reference
If you are using class components, you donât need to worry about recreating new references for callbacks, because they can have instance methods that always have the same reference. However, these component instances will need to create unique callbacks for separate child list items, or capture a value in an anonymous function and pass it to the child component. All of these lead to the result of having to create a new reference each time it re-renders, and so it will create more objects and child props when re-rendering -> meaning it will take up a little more memory after each re-render, and GC has to work more often. With class-components, React does not provide a built-in feature to optimize these cases.
For functional components, React provides 2 hooks to help you reuse references when needed for optimization:
useMemo
: Helps âmemoizeâ (remember) the result of a complex calculation or a value (such as an object, array) so that it is only calculated/recreated when dependencies change. This is useful when passing objects/arrays as props down to a memoized child component (React.memo
).useCallback
: Helps âmemoizeâ a callback function reference. This is especially important when passing callbacks as props down to child components optimized withReact.memo
, because otherwise, a new function reference will be created each time the parent component renders, negating the effect ofReact.memo
on the child component.
Memoize everything?
As mentioned above, you donât need to use useMemo
and useCallback
on every function or object that you pass down as props. They are only really needed when:
- You pass them down to a child component that has been wrapped by
React.memo
(orPureComponent
,shouldComponentUpdate
) and you want to avoid unnecessary re-renders for that child component. - The value/function is used as a dependency in another hook (such as
useEffect
,useMemo
,useCallback
). - The calculation of the value (with
useMemo
) is really resource-intensive.
Using them unnecessarily can make the code more complex and even add a little extra cost for comparing dependencies.
Another frequently asked question is âWhy doesnât React wrap everything with React.memo
by default?â
Dan Abramov has repeatedly pointed out that even memoization still costs you the comparison of props: O(prop count) complexity, and in some cases memoization can never prevent re-rendering because the component always receives new props. For example, see this Twitter thread by Dan:
Why doesnât React put memo() around every component by default? Isnât it faster? Should we make a benchmark to check?
Ask yourself:
Why donât you put Lodash memoize() around every function? Wouldnât that make all functions faster? Do we need a benchmark for this? Why not?
In addition, applying React.memo()
to all components by default will result in bugs in cases where developers intentionally/unintentionally mutate objects (obj.a = 'changed'
) instead of updating them in an immutable way (obj = {...obj, a: 'changed' }
).
State updates in React should be performed immutably. There are 2 main reasons:
- Ensure change detection: Optimization mechanisms such as
React.memo
,PureComponent
, andshouldComponentUpdate
rely on reference comparison (shallow equality) to decide whether to re-render or not. If you mutate an object/array directly, its reference does not change, React will think that nothing has changed and skip the necessary re-rendering, resulting in the UI not being updated correctly. - Predictability and Debugging: Immutability makes it easier to track and understand the data flow. You know that state is not being changed unexpectedly in other places in the code.
To make it easier to understand, letâs go through some examples.
If you mutate, then the reference of the current someValue
will be the same as the old someValue
reference, and the shallow comparison in shouldComponentUpdate
(or React.memo
/PureComponent
) will return true
(meaning nothing has changed), resulting in skipping the re-render and the UI not updating.
Another problem is with the useState
and useReducer
hooks. React will skip re-rendering if you call the setter function (like setTodos
) with the same object/array reference as the current state. For example:
const [todos, setTodos] = useState(someTodosArray);
const onClick = () => {
todos[3].completed = true;
setTodos(todos);
}
Then the component will not be able to re-render, you need to pass a new reference to setTodos
.
const onClick = () => {
const newTodos = todos.slice();
newTodos[3].completed = true;
setTodos(newTodos);
}
Note that there is a clear difference in behavior between class component this.setState
and functional component useState
/ useReducer
regarding mutations and re-rendering: this.setState
does not care about whether you mutate or pass in a new reference, it always performs a re-render, for example, the code below will still re-render:
const {todos} = this.state;
todos[3].completed = true;
this.setState({todos});
In summary: React, and its ecosystem, is designed based on the assumption that state updates are immutable. Mutating state can lead to unexpected behavior, difficult debugging, and inconsistent UI. Always create new objects/arrays when updating state instead of directly changing the old one!
Context and Rendering Behavior
Reactâs Context API is a mechanism to create and share a certain value (state) down the component tree without having to pass props through each level. Any component within the Provider
of the Context can âreadâ that value.
Context is primarily a distribution mechanism for state (state propagation mechanism), not a complete management solution for state. You still need to manage how that state is created and updated yourself. Typically, this is done by storing the state in a React component (using useState
or useReducer
) and then passing that state into the value
prop of the Context Provider.
Basic Context
The context provider takes a value
prop, such as <MyContext.Provider value={42}>
. Child components can consume that context by rendering the context consumer and getting the value in the context using a render prop, for example:
<MyContext.Provider>
{(value) => <div>{value}</div>}
</MyContext.Provider>
Or with functional components, you can use the useContext
hook:
const value = useContext(MyContext);
Updating Context Values
React checks if the value in the context provider has changed when the components wrapped outside the context-provider re-render. If the value of the context-provider is a new reference, then React will know that the value has changed, and the components consuming that context need to be updated.
Note that passing a new object into the context provider will cause it to update:
function GrandchildComponent() {
const value = useContext(MyContext);
return <div>{value.a}</div>
}
function ChildComponent() {
return <GrandchildComponent />
}
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState("text");
const contextValue = {a, b};
return (
<MyContext.Provider value={contextValue}>
<ChildComponent />
</MyContext.Provider>
)
}
In the example above, each time ParentComponent
re-renders, React will understand that MyContext.Provider
has been changed to a new value (although it may not actually be), and find the components consuming MyContext
to mark as needing an update. When a context provider has a new value (new value reference), all nested components consuming that context will be forced to re-render.
Currently, there is no way for a component consuming a context to skip re-rendering due to the context value updating to a new value.
State Update, Context and Re-Renders
To summarize some ideas from earlier:
- Calling
setState()
will cause the component to re-render - React will by default render recursively down all nested components
- The context provider is provided a value from the component rendering it
- That value usually comes from the parent component of the component rendering the context provider
This means that by default, every update to the parent component that renders the context provider will cause all of its child components to re-render, regardless of whether those components read the value from the context or not.
If we look back at the Parent/Child/Grandchild
example above, we can see that GrandchildComponent
will be re-rendered, but not because of the context-update - it re-renders because ChildComponent
re-renders. In the example above, there is no optimization to remove âunnecessaryâ re-renders, so React will by default re-render ChildComponent
and GrandchildComponent
each time ParentComponent
re-renders. If ParentComponent
changes the value in MyContext.Provider
, GrandchildComponent
will read the new value when it re-renders and use it, but it is not the context update that causes it to re-render.
Context Updates and Re-render Optimization
Letâs try modifying the example above to perform some optimizations for re-rendering, we will also add a GreatGrandchildComponent
at the bottom:
function GreatGrandchildComponent() {
return <div>Hi</div>
}
function GrandchildComponent() {
const value = useContext(MyContext);
return (
<div>
{value.a}
<GreatGrandchildComponent />
</div>
}
function ChildComponent() {
return <GrandchildComponent />
}
const MemoizedChildComponent = React.memo(ChildComponent);
function ParentComponent() {
const [a, setA] = useState(0);
const [b, setB] = useState("text");
const contextValue = {a, b};
return (
<MyContext.Provider value={contextValue}>
<MemoizedChildComponent />
</MyContext.Provider>
)
}
OK, now if we call setA(42)
:
ParentComponent
will re-render- A new
contextValue
reference will be created - React sees that
MyContext.Provider
has been updated to a new value, so the consumers ofMyContext
will need to be updated. (1) - React will try to re-render
MemoizedChildComponent
, but because it is wrapped inReact.memo()
, because no props are passed in, so no props will change. React will skip renderingChildComponent
. - However, because the value of
MyContext
has been changed (due to 1), there will be some consume components that need to know. - React continues to iterate down and sees that
GrandchildComponent
is reading the value inMyContext
, soGrandchildComponent
needs to be re-rendered.GrandchildComponent
is then re-rendered, because the context value changed. - Because
GrandchildComponent
renders, its child componentGreatGrandchildComponent
will also be re-rendered as well.
In other words, as in Sophie Alpertâs tweet:
That React Component Right Under Your Context Provider Should Probably Use React.memo
Redux and Rendering Behavior
âUsing Redux or Context?â seems to be the most frequently mentioned question in debates in the React community lately, after React Hooks became popular. In fact, Redux and Context are 2 different tools for doing different things, details will be mentioned below.
A conclusion often drawn in debates between Context and Redux is: âRedux only re-renders the components that really need to re-render, so its performance is better than Contextâ (âcontext makes everything render, Redux doesnât, use Reduxâ).
That is partly true, but the answer is actually more nuanced than that.
Redux Subscriptions
Many people say that âRedux actually uses Context underneathâ, which is partly true, but Redux uses Context to pass the Redux store instance, not the state value. This means that Redux always passes an unchanged context value into <ReactReduxContext.Provider>
throughout the Appâs runtime.
The Redux store runs all subscriber notification callbacks each time an action is dispatched. React Components that need to use Redux always subscribe to the Redux store, read the new value received from subscriber callbacks, compare that value, and force re-render if the new value is different from the current value. The subscription callback function is handled outside the React app (not managed by React), and the React app is only notified when a React Component subscribes to data in Redux, and that data has just been changed (based on the value returned by mapState
or useSelector
).
This behavior of React leads to very different performance characteristics than Context. Yes, it seems that fewer components will have to re-render throughout the runtime, but Redux will always have to run the mapState/useSelector
function on the entire component tree (meaning it will run that function on each component subscribed to the Redux store) each time the store state is updated. In most cases, the cost of running these selector functions is less than the cost for React to perform the re-render phases, so it usually provides more performance benefits for the app, however, if those selector functions include complex calculations, expensive transformations, or unintentionally always return a new value, things can be slowed down.
Difference between connect
and useSelector
connect
is a higher-order component, it returns a wrapper component that performs all the work from subscribing to the store, running mapState
and mapDispatch
, and passing combined props from the store down to your component.
The connect
wrapper component always works similarly to PureComponent/React.memo()
, but with one difference: connect
will only make your component re-render if the combined props passed down to the component have changed. Usually, the final combined props passed down are a combination of {...ownProps, ...stateProps, ...dispatchProps}
, so each new props reference from the parent component passed down will cause your component to re-render, similar to PureComponent/React.memo()
. Besides parent props, each new reference returned from Mapstate
will also cause the component to re-render (so you can customize the way ownProps/stateProps/dispatchProps
are merged, to change this re-render behavior).
On the other hand, useSelector
is a hook called inside a functional component. Therefore, useSelector
has no way to prevent your component from re-rendering when its parent component re-renders!
This is a key performance difference between connect
and useSelector
. With connect
, every connected component will act like PureComponent
.
So, if you are only using functional components and useSelector
, it is likely that most of the components in the App will re-render more due to changes from the Redux store than if you used connect
, because there will be nothing blocking the child component from re-rendering after its parent component re-renders.
If that becomes a performance issue (too many re-renders), then you should wrap the functional components with React.memo()
to block the child component from re-rendering after the parent component.
Evan You (Vueâs author) said about React Hooks & Rendering
âReact hooks leaves most of the component-tree level optimization (i.e. prevent unnecessary re-renders of child components) to the developers, requiring explicit usage of useMemo in most cases. Also, whenever a React component receives the children prop, it almost always has to re-render because the children prop will always be a fresh vdom tree on each render. This means a React app using hooks will be over-re-rendering by default. Whatâs worse, optimizations like useMemo cannot easily be auto-applied because (1) it requires the correct deps Array and (2) blindly adding it everywhere may block updates that should happen, similar to PureComponent. Unfortunately, most developers will be lazy and will not aggressively optimize their apps everywhere, so most React apps using hooks will be doing a lot of unnecessary CPU work.â
in Issue #89
My comment:
The comments of Evan You, the creator of Vue.js, are not only a perspective from a âcompetitorâ but also accurately reflect the concerns and difficulties that many developers encounter when working with React in real-world projects, especially regarding performance and optimization.
- The Burden of Manual Optimization: The core point that Evan points out is absolutely correct: React Hooks place most of the responsibility for optimizing re-renders (via
useMemo
,useCallback
,React.memo
) on the shoulders of the programmer. In practice, this often leads to two extremes: either ignoring optimization, accepting the âover-renderingâ situation (rendering more than necessary) which reduces performance, especially as the app becomes more complex; or trying to optimize âthoroughlyâ, leading to code being âpollutedâ by countless nesteduseMemo
,useCallback
hooks, reducing readability and maintainability. Correctly identifying thedependencies array
for these hooks is also a common source of errors. - âOver-renderingâ is the default: The Virtual DOM model and the top-down recursive re-render mechanism of React mean that, without optimization intervention, any state change in the parent component can trigger re-renders for the entire tree of child components below, even if their props have not changed at all. The cost of diffing the VDOM and re-running the code of components that havenât actually changed is a significant waste of CPU resources.
- Vue.js - A More Efficient Solution? In contrast to React, Vue.js uses a reactivity system based on automatic dependency tracking. Vue knows exactly which UI components depend on which state and only re-renders the parts that are actually needed when the state changes. This approach significantly reduces the burden of manual optimization for the programmer, providing good âout-of-the-boxâ performance in many cases where React requires manual optimization.
- The Rise of Solid.js: Recognizing the limitations in Reactâs VDOM model and manual optimization, new frameworks like Solid.js have emerged. Solid.js takes inspiration from Reactâs syntax (especially Hooks) but completely eliminates the Virtual DOM. Instead, it uses âfine-grained reactivityâ (similar to Vue but at a more detailed level and in the compilation process instead of runtime) to directly update the DOM when the state changes. The result is superior performance, close to vanilla JavaScript, and developers almost donât need to worry about
useMemo
oruseCallback
for optimizing component re-rendering as in React.
(Update*) React Concurrent Mode and React Compiler in React 19
Concurrent Mode - Solution for âover-renderingâ
React 19 has officially made Concurrent Mode a default feature, marking a significant step in addressing the long-standing âover-renderingâ issue in React. Concurrent Mode introduces a new rendering model that allows React to control and prioritize rendering tasks more intelligently.
How does Concurrent Mode work?
Instead of performing all rendering tasks synchronously and uninterruptibly as before, Concurrent Mode allows React to:
- Interrupt rendering: React can pause, resume, or cancel a rendering process based on the priority of updates.
- Parallel rendering: Can prepare multiple versions of UI simultaneously without blocking the main thread.
- Selective Hydration: Allows different parts of the application to be hydrated independently, prioritizing the parts users interact with.
Comparison with other frameworks:
-
Vue.js:
- Uses fine-grained reactivity system to track component dependencies precisely
- Only re-renders components actually affected by state changes
- Doesnât need a special mode like Concurrent Mode as itâs already optimized by default
-
Solid.js:
- Doesnât use Virtual DOM, instead compiles components into reactive computations
- Updates are applied directly to the DOM, only the parts that actually change
- Higher performance than both Vue and React by completely eliminating diffing and reconciliation overhead
How Concurrent Mode addresses over-rendering:
-
Smarter update handling:
- Categorizes updates into urgent and non-urgent
- Can defer non-urgent updates to prioritize urgent ones
- Avoids blocking UI for non-critical updates
-
Rendering tree optimization:
- Transition API allows marking updates that can be deferred
- Suspense helps coordinate loading states and avoid cascading spinners
- Smarter automatic batching to reduce re-render counts
-
Improved user experience:
- UI remains responsive even while processing heavy updates
- Loading states are displayed more smoothly
- Prevents UI âjankâ when multiple updates occur simultaneously
While Concurrent Mode doesnât completely solve the over-rendering issue like Vueâs or Solid.jsâs approach, it provides powerful tools and APIs for developers to better control the rendering process and create smoother user experiences. Combined with React Compiler (introduced below), React 19 has made significant strides in improving the frameworkâs default performance.
React Compiler in React 19
Recognizing the burden of manual optimization placed on developers as mentioned earlier (with useMemo
, useCallback
, React.memo
), the React team has developed a breakthrough solution: React Compiler. Introduced with React 19, React Compiler is expected to change how we write and optimize React applications.
What is React Compiler?
At its core, React Compiler is an optimizing compiler designed to automatically analyze your React code and apply memoization techniques similar to useMemo
, useCallback
, and React.memo
automatically during the build process. It understands JavaScript semantics and React Hooks rules to determine which parts of a component can be safely memoized without changing the applicationâs behavior.
Solving the manual optimization problem:
With React Compiler, developers will no longer need to âsprinkleâ useMemo
and useCallback
throughout their code to optimize re-renders. The compiler will automatically handle this work. This brings several benefits:
- Simpler code: Your code becomes cleaner, more readable, and easier to maintain when freed from manual optimization hooks.
- Fewer bugs: Reduces the risk of errors caused by forgetting to add dependencies or adding incorrect dependencies to
useMemo
/useCallback
. - Better âdefaultâ performance: Applications will have better performance out of the box without developers having to deeply intervene in optimizing each component.
React Compiler isnât an entirely new idea (taking inspiration from compilers in Svelte and Solid.js), but integrating it into a large and established ecosystem like React is a significant step forward. It demonstrates the React teamâs effort to improve the development experience and address one of the libraryâs biggest weaknesses. Although still in development and testing phase (being used internally at Meta), React Compiler promises to be an important part of Reactâs future.
Reference
- https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/
- https://reactjs.org/docs/reconciliation.html#elements-of-different-types
- https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous
- https://github.com/vuejs/rfcs/issues/89