tu-huynh
tuhuynh
.com
Blog

React Rendering under the hood

TĂș @ Grokking Newsletter wrote

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) and useLayoutEffect 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 setters
    • useReducer dispatches
  • Other:
    • Calling ReactDOM.render(<App/>) again, equivalent to calling forceUpdate() at the root component.
    • Using flushSync to force React to perform state updates synchronously, escaping the automatic batching mechanism.

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() in B, 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 of B. B returns C.
  • C is not marked as needing an update. However, since its parent component B has just re-rendered, React will re-render the child component C. C returns D.
  • D is similar to the above, although not marked as needing an update, because C re-rendered, React also performs a re-render of D.

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 returns false, 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 replaces React.Component, implementing the shouldComponentUpdate 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 by React.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 with React.memo, because otherwise, a new function reference will be created each time the parent component renders, negating the effect of React.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:

  1. You pass them down to a child component that has been wrapped by React.memo (or PureComponent, shouldComponentUpdate) and you want to avoid unnecessary re-renders for that child component.
  2. The value/function is used as a dependency in another hook (such as useEffect, useMemo, useCallback).
  3. 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, and shouldComponentUpdate 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 of MyContext will need to be updated. (1)
  • React will try to re-render MemoizedChildComponent, but because it is wrapped in React.memo(), because no props are passed in, so no props will change. React will skip rendering ChildComponent.
  • 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 in MyContext, so GrandchildComponent needs to be re-rendered. GrandchildComponent is then re-rendered, because the context value changed.
  • Because GrandchildComponent renders, its child component GreatGrandchildComponent 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

Come to Vue


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 nested useMemo, useCallback hooks, reducing readability and maintainability. Correctly identifying the dependencies 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 or useCallback 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:

  1. 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
  2. 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:

  1. 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
  2. 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
  3. 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