Armstrong Olusoji
Posted on November 10, 2024
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.
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>
);
}
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)
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>
);
}
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>
);
};
First, we define useActionState()
by destructuring the returned array as follows:
[nameData, actionFunction]
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:
- Receive the new value
- Make a
put
request to the server with the new value. - Make a
get
request from the server - Display the result of the
get
request on the client. - 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;
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;
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;
}
The compiler code is not relevant to you, but it is worth noting what exactly it is doing to optimize the code.
- The compiler creates a cache for each component as shown below:
const $ = \_c(7)
This cache stores the current and previous values; as well as rendered elements.
- In the
CounterDisplay
component, the compiler checks if any relevant state variables have changed. - If anything has changed, the compiler creates a new
<p>
element with the latest value. It also updates the cache. - 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;
}
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.
Posted on November 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.