DEV Community

Armstrong Olusoji
Armstrong Olusoji

Posted on

How React19 optimizes rendering

React 19 offers several new features. And some of those features improve rendering behavior. This is important because rendering is central to React. At its best, rendering improves performance and user experience. At its worst, it makes our apps unusable. This article, therefore, explores how React 19 optimizes rendering. It covers the following:

  • Form actions: a better way to handle forms, and the related asynchronous operations.
  • useOptimistic: a new way to improve user experience by displaying instant updates while waiting for asynchronous functions to complete.
  • React Compiler: Although not part of React 19, it optimizes rendering performance by automatically memoizing components.
  • Server Components: Reduce client-side rendering load by handling components on the server.

Please note that this article compares the new features with traditional React paradigms. Thus, only readers who have some React experience will appreciate it.

Streamlining Asynchronous Operations with Actions

Asynchronous (async) operations are crucial in programming. And managing them in React can be troublesome. For example, the current pattern to manage forms in React is as follows:

  • Store the form data in a state variable.
  • Modify the schema, or user interface with the form data.
  • Handle transitions and status updates with a different set of state variables.

The issue with this approach is that multiple state variables trigger multiple re-renders. Actions, however, are functions that handle all these things with neither state management nor event handlers. Without an action, you need several moving pieces. With an action, you only need one function. The image below illustrates this difference.

Untitled-2024-07-24-1534

As seen above React 19 actions provide an easy way to handle async functions with minimal re-renders. Let us demonstrate this with a simple form component.

Testing a Form Action

A common React use case is to use the data collected from a form to do something on the server. Here's how to do that with a form action:

export default function Form() {
  async function handleSubmit(formData) {
    const name = formData.get("name");
    console.log(`Submitted: Name - ${name}`);
    // Simulate an API call
    await new Promise((resolve) => setTimeout(resolve, 3000));
    console.log(`The name: ${name} has been successfully sent to the server`);
  }

  return (
    <div>
      <form action={handleSubmit}>
        <label htmlFor="input">Name:</label>
        <input id="input" type="text" name="name" required />
        <button type="submit">Submit</button>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Recording 2024-09-05 112326

The form action here is the async function handleSubmit. It automatically receives data from the form. In this case, formData represents that data.

We then use the .get method to query formData and collect the value with the key name.

Finally, In the JSX we append handleSubmit as the action. We also reference the input data with the prop name.

But what if - depending on the status of handleSubmit - we want to conditionally render a button? We would have to combine the action with a hook called useFormStatus().

Testing useFormStatus

In the second demonstration, we conditionally render the button with a new hook called useFormStatus(). When handleSubmit is in progress, the button with the text submitting will be rendered. To achieve this, we would typically use a state variable like:

const [isPending, setIsPending] = useState(true)
Enter fullscreen mode Exit fullscreen mode

With useFormStatus(), we can simply do the following:

import React from "react";
import { useFormStatus } from "react-dom";

function Submit() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting..." : "Submit"}
    </button>
  );
}

export default function Form() {
  async function handleSubmit(formData) {
    const name = formData.get("name");
    console.log(`Submitted: Name - ${name}`);
    // Simulate an API call
    await new Promise((resolve) => setTimeout(resolve, 3000));
    console.log(`The name: ${name} has been successfully sent to the server`);
  }

  return (
    <div>
      <form action={handleSubmit}>
        <label htmlFor="input">Name:</label>
        <input id="input" type="text" name="name" required />
        <Submit />
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Recording 2024-09-05 120741

Inside the Submit component, we call the useFormStatus() hook. This hook returns a status that is either true or false. Similar to a promise, the status represents the resolution of the operations in an async function. Thus If the target function has completed its work, the hook returns true. Otherwise it returns false.

In this example, we use the pending attribute of useFormStatus() to check if handleSubmit has executed all its code. Then, the button element renders a different text depending on the status of handleSubmit Finally, we use the Submit component inside the Form component.

Now that Submit is a child component of Form, handleSubmit becomes the target function. Thus useFormStatus() will read the status of handleSubmit.

So far we saved name, and simulated an API call with it. We have also changed the button based on the status of handleSubmit. Now, let us try a more advanced feature of actions.

Testing useActionState()

useActionState() is a hook that stores the state of a form Action and allows us to use it. For this example, we store the history of every name input.

import React, { useActionState } from "react";

export const Form = () => {
  const [nameData, actionFunction] = useActionState(handleSubmit, {
    currentName: "",
    nameHistory: [],
  });

  async function handleSubmit(prevState, formData) {
    await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulating API call
    const newName = formData.get("name");

    // Update the name history, keeping only the last 3 names
    const updatedHistory = [prevState.currentName, ...prevState.nameHistory];

    return {
      currentName: newName,
      nameHistory: updatedHistory.filter(Boolean),
    };
  }

  return (
    <div>
      <form action={actionFunction}>
        <div>
          <label htmlFor="name">Enter your name:</label>
          <input type="text" id="name" name="name" required />
        </div>
        <Submit />
      </form>

      {nameData.currentName && <h2>{`Hello, ${nameData.currentName}!`}</h2>}

      {nameData.nameHistory.length > 0 && (
        <div>
          <h3>Previously entered names:</h3>
          <ul>
            {nameData.nameHistory.map((name, index) => (
              <li key={index}>{name}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Recording 2024-09-05 121851

First, we define useActionState() by destructuring the returned array as follows:

[nameData, actionFunction] 
Enter fullscreen mode Exit fullscreen mode

nameData refers to the return value of our form action. actionFunction refers to the function we are targeting.

Inside the useActionState() hook, we set handleSubmit as the function to target and an object as the default value of nameData.

Next, handleSubmit() receives a prevState argument which represents the previous state of our form action. It automatically reads and stores the previous value of formData. Hence we can use prevState in a spread operator to create a history of names.

Finally, we make one last change to the JSX. We replace handleSubmit with actionFunction as the action. This is possible because actionFunction references handleSubmit

Recap: We have covered a lot here. Combined, these three hooks help us work with form conveniently. Without them, we would be keeping track of multiple state variables - causing several unnecessary re-renders.

useOptimistic

State changes trigger a render in React. But after that, React creates a new virtual DOM based on the new state. Then, it compares the new virtual DOM with the existing DOM, and updates the existing DOM. This process is called reconciliation.

But what if you want to display the future state of an element to the user? For example, if they edit their name you need to do the following asynchronous function:

  1. Receive the new value
  2. Make a put request to the server with the new value.
  3. Make a get request from the server
  4. Display the result of the get request on the client.
  5. React will then build the new virtual DOM and perform reconciliation.

With this process, the user has to wait for some time before the change is visible. Yet, you can use an optimistic update to provide instant feedback.

useOptimistic is a way to display data on the interface before the underlying async functions are complete. With this hook, reconciliation happens between the optimistic state and the DOM.

Here's an example:

import { useOptimistic, useActionState } from "react";

function Form() {
  const [name, actionFunction] = useActionState(handleSubmit, "");
  const [optimisticName, setOptimisticName] = useOptimistic(name);

  async function handleSubmit(prevState, formData) {
    const newName = formData.get("name");

    await setOptimisticName(newName);
    await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulating API call
    return `${newName}: returned from server`;
  }

  return (
    <>
      <form action={actionFunction}>    
          <input type="text" id="name" name="name" required />
        <button>submit</button>
      </form>
      <p>{optimisticName}</p>
    </>
  );
}

export default Form;
Enter fullscreen mode Exit fullscreen mode

Recording 2024-09-05 141639

This component utilizes useActionState() for managing the form data and its state. You will also notice the useOptimistic hook. It works like useState()!

Inside handleSubmit we collect name from formData. Then we use its value with setOpimisticName().

In the JSX, we render optimisticName instead of name. Hence, while React waits for our async function to resolve, the optimistic state will immediately be rendered to the user. Once the async function resolves, React will render the new value of name. If the function is not resolved, React will render the old value of name.

The React Compiler

The new React compiler automatically optimizes rendering performance in React applications. The compiler's features are based on its understanding of React rules, and JavaScript. This allows it to automatically optimize the developer's code.

Without the compiler, developers optimize rendering with features such as useMemo, useCallback, and more. The below example is a component that would typically need useCallback:

import React, { useState } from "react";

function CounterDisplay({ count, setCount }) {
  console.log("CounterDisplay re-rendered", count);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

function Counter() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  console.log("Counter re-rendered");

  return (
    <div>
      <CounterDisplay count={count1} setCount={setCount1} />
      <CounterDisplay count={count2} setCount={setCount2} />
    </div>
  );
}

export default Counter;
Enter fullscreen mode Exit fullscreen mode

Recording 2024-09-05 211410

CounterDisplay is a simple counter component. When a user clicks the Increment button, a message logs to the console.

Now, inside the Counter component, we render CounterDisplay twice. Open your console, you will notice that when we click on CounterDisplay1, CounterDisplay2 also re-renders. We, however, want to re-render only the CounterDisplay instance whose state has changed.

We would typically optimize this component with useCallback(). But the React compiler makes that unnecessary. Since the compiler understands JavaScript and React rules, it will automatically memoize the component. The compiler-optimized version of the above code is as follows:

function CounterDisplay(t0) {
  const $ = _c(7);  // Create a cache array with 7 elements
  const { count, setCount } = t0;
  console.log("CounterDisplay re-rendered", count);

  let t1;
  if ($[0] !== count) {  // Check if count has changed
    t1 = <p>Count: {count}</p>;  // Create new paragraph element
    $[0] = count;  // Update cache with new count
    $[1] = t1;  // Cache the new paragraph element
  } else {
    t1 = $[1];  // Reuse cached paragraph element if count hasn't changed
  }

  let t2;
  if ($[2] !== setCount) {  // Check if setCount function has changed
    t2 = <button onClick={() => setCount((c) => c + 1)}>Increment</button>;
    $[2] = setCount;
    $[3] = t2;
  } else {
    t2 = $[3];
  }


let t3;
  if ($[4] !== t1 || $[5] !== t2) {  // Check if either child element has changed
  t3 = (
  <div>
  {t1}
  {t2}
  </div>
  );  // Create a new div element with updated children
  $[4] = t1;  // Update cache with new t1 reference
  $[5] = t2;  // Update cache with new t2 reference
  $[6] = t3;  // Cache the new div element
  } else {
  t3 = $[6];  // Reuse cached div element if children haven't changed
  }
  return t3;
  }
Enter fullscreen mode Exit fullscreen mode

The compiler code is not relevant to you, but it is worth noting what exactly it is doing to optimize the code.

  1. The compiler creates a cache for each component as shown below:
const $ = \_c(7)
Enter fullscreen mode Exit fullscreen mode

This cache stores the current and previous values; as well as rendered elements.

  1. In the CounterDisplay component, the compiler checks if any relevant state variables have changed.
  2. If anything has changed, the compiler creates a new <p> element with the latest value. It also updates the cache.
  3. If nothing has changed it reuses the previously rendered version. This means that only elements whose states have changed will re-render.

This process is repeated inside the Counter component.

Note: We have only shown the compiler code for CounterDisplay. But a similar optimization will happen in Counter.

The optimized Counter function

function Counter() {
  const $ = _c(7);  // Create a cache array with 7 elements
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);
  console.log("Counter re-rendered");
  let t0;
  if ($[0] !== count1) {  // Check if count1 has changed
    t0 = ;
    $[0] = count1;
    $[1] = t0;
  } else {
    t0 = $[1];  // Reuse cached CounterDisplay element if count1 hasn't changed
  }

  let t1;
  if ($[2] !== count2) {
    t1 = ;
    $[2] = count2;
    $[3] = t1;
  } else {
    t1 = $[3];
  }

  let t2;
  if ($[4] !== t0 || $[5] !== t1) {  // Check if either CounterDisplay element has changed
    t2 = (

        {t0}
        {t1}

    );  // Create new div element with updated children
    $[4] = t0;
    $[5] = t1;
    $[6] = t2;
  } else {
    t2 = $[6];  // Reuse cached div element if children haven't changed
  }
  return t2;
}
Enter fullscreen mode Exit fullscreen mode

Server Components

Server components are the latest iteration in React's attempt to load content better. So far, we have client-side rendering (CSR), server-side rendering (SSR), and static site generation (SSG)

Leading up to Server Components

In CSR, JavaScript modules load, and HTML is built at request time. All this happens on the user's browser. There are two issues with this approach.

  • No content will rendered until the aforementioned protocol is complete.
  • The protocol is executed in the user's browser. As such, performance is subject to user-specific issues like network conditions.

SSR solves some of these issues. In SSR, the JavaScript modules run, and API calls happen on the server. The HTML is also built from the server, and then sent to the browser. This is a significant upgrade on CSR. Yet, both approaches are similar in that the HTML is built at the request time. In SSR, the user may see a shell of the content (such as a header) but not the full thing.

This leads us to SSG. SSG happens on the server, like SSR. But in SSG the HTML is built the moment the application mounts (at build time). Thus, all pages render before the user ever navigates to them. This approach, though, is only useful for static sites where nothing changes.

How Server Components are an upgrade

This all leads us to Server Components. Server Components are React components that exist on the server. Server Components improve on both SSR and SSG in the following ways:

  • SSG and SSR both require network requests to the server. However, server components are built on the server. This makes things faster
  • Server Components render only once. This eliminates unnecessary re-renders. However, it also means they do not support hooks or state management.
  • Sever Components render before bundling, or request time - a significant upgrade on both SSR and SSG.
  • Server Components can also read file systems, or other server resources without API calls. You don't need a web server for many use cases.
  • Server components can be asynchronous. Hence, the functions nested inside them can run without using useEffect.
  • Server Components support 'streaming'. This means that HTML renders as it is being built, with the rest following in real-time. This makes for quicker outcomes.

With these features, we can render things faster. This helps our website performance in metrics such as:

  • First Contentful Paint: when the user can see the layout.
  • Time To Interactive: when the user can interact with the interface.
  • Large Contentful Paint: all content, including content pulled from our database, are available. This is good for Search Engine Optimization, and user experience.

Conclusion

React 19's new features represent a significant leap forward in managing asynchronous operations and optimizing performance. By simplifying form handling, providing tools for optimistic updates, and introducing Server Components, React continues to evolve, offering developers more efficient ways to build responsive and user-friendly applications.

Top comments (0)