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:
exportdefaultfunctionForm(){asyncfunctionhandleSubmit(formData){constname=formData.get("name");console.log(`Submitted: Name -${name}`);// Simulate an API callawaitnewPromise((resolve)=>setTimeout(resolve,3000));console.log(`The name:${name} has been successfully sent to the server`);}return(<div><formaction={handleSubmit}><labelhtmlFor="input">Name:</label><inputid="input"type="text"name="name"required/><buttontype="submit">Submit</button></form></div>);}
The form action here is the async functionhandleSubmit
. It automatically receives data from the form. In this case,formData
represents that data.
We then use the.get
method to queryformData
and collect the value with the keyname
.
Finally, In theJSX
we appendhandleSubmit
as the action. We also reference the input data with the propname
.
But what if - depending on the status ofhandleSubmit
- we want to conditionally render a button? We would have to combine the action with a hook calleduseFormStatus()
.
Testing useFormStatus
In the second demonstration, we conditionally render the button with a new hook calleduseFormStatus()
. WhenhandleSubmit
is in progress, the button with the textsubmitting
will be rendered. To achieve this, we would typically use a state variable like:
const[isPending,setIsPending]=useState(true)
WithuseFormStatus()
, we can simply do the following:
importReactfrom"react";import{useFormStatus}from"react-dom";functionSubmit(){const{pending}=useFormStatus();return(<buttontype="submit"disabled={pending}>{pending?"Submitting...":"Submit"}</button>);}exportdefaultfunctionForm(){asyncfunctionhandleSubmit(formData){constname=formData.get("name");console.log(`Submitted: Name -${name}`);// Simulate an API callawaitnewPromise((resolve)=>setTimeout(resolve,3000));console.log(`The name:${name} has been successfully sent to the server`);}return(<div><formaction={handleSubmit}><labelhtmlFor="input">Name:</label><inputid="input"type="text"name="name"required/><Submit/></form></div>);}
Inside theSubmit
component, we call theuseFormStatus()
hook. This hook returns a status that is eithertrue
orfalse
. Similar to apromise
, the status represents the resolution of the operations in an async function. Thus If the target function has completed its work, the hook returnstrue
. Otherwise it returnsfalse
.
In this example, we use thepending
attribute ofuseFormStatus()
to check ifhandleSubmit
has executed all its code. Then, thebutton
element renders a different text depending on the status ofhandleSubmit
Finally, we use theSubmit
component inside theForm
component.
Now thatSubmit
is a child component ofForm
,handleSubmit
becomes the target function. ThususeFormStatus()
will read the status ofhandleSubmit
.
So far we savedname
, and simulated an API call with it. We have also changed the button based on the status ofhandleSubmit
. Now, let us try a more advanced feature of actions.
TestinguseActionState()
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.
importReact,{useActionState}from"react";exportconstForm=()=>{const[nameData,actionFunction]=useActionState(handleSubmit,{currentName:"",nameHistory:[],});asyncfunctionhandleSubmit(prevState,formData){awaitnewPromise((resolve)=>setTimeout(resolve,1000));// Simulating API callconstnewName=formData.get("name");// Update the name history, keeping only the last 3 namesconstupdatedHistory=[prevState.currentName,...prevState.nameHistory];return{currentName:newName,nameHistory:updatedHistory.filter(Boolean),};}return(<div><formaction={actionFunction}><div><labelhtmlFor="name">Enteryourname:</label><inputtype="text"id="name"name="name"required/></div><Submit/></form>{nameData.currentName&&<h2>{`Hello,${nameData.currentName}!`}</h2>}{nameData.nameHistory.length>0&&(<div><h3>Previouslyenterednames:</h3><ul>{nameData.nameHistory.map((name,index)=>(<likey={index}>{name}</li>))}</ul></div>)}</div>);};
First, we defineuseActionState()
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 theuseActionState()
hook, we sethandleSubmit
as the function to target and an object as the default value ofnameData
.
Next,handleSubmit()
receives aprevState
argument which represents the previous state of our form action. It automatically reads and stores the previous value offormData
. Hence we can useprevState
in a spread operator to create a history of names.
Finally, we make one last change to theJSX
. We replacehandleSubmit
withactionFunction
as the action. This is possible becauseactionFunction
referenceshandleSubmit
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 newvirtual DOM
based on the new state. Then, it compares the newvirtual DOM
with the existingDOM
, and updates the existingDOM
. This process is calledreconciliation
.
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 theDOM
.
Here's an example:
import{useOptimistic,useActionState}from"react";functionForm(){const[name,actionFunction]=useActionState(handleSubmit,"");const[optimisticName,setOptimisticName]=useOptimistic(name);asyncfunctionhandleSubmit(prevState,formData){constnewName=formData.get("name");awaitsetOptimisticName(newName);awaitnewPromise((resolve)=>setTimeout(resolve,1000));// Simulating API callreturn`${newName}: returned from server`;}return(<><formaction={actionFunction}><inputtype="text"id="name"name="name"required/><button>submit</button></form><p>{optimisticName}</p></>);}exportdefaultForm;
This component utilizesuseActionState()
for managing the form data and its state. You will also notice theuseOptimistic
hook. It works likeuseState()
!
InsidehandleSubmit
we collectname
fromformData
. Then we use its value withsetOpimisticName()
.
In the JSX, we renderoptimisticName
instead ofname
. 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 ofname
. If the function is not resolved, React will render the old value ofname
.
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 asuseMemo
,useCallback
, and more. The below example is a component that would typically needuseCallback
:
importReact,{useState}from"react";functionCounterDisplay({count,setCount}){console.log("CounterDisplay re-rendered",count);return(<div><p>Count:{count}</p><buttononClick={()=>setCount((c)=>c+1)}>Increment</button></div>);}functionCounter(){const[count1,setCount1]=useState(0);const[count2,setCount2]=useState(0);console.log("Counter re-rendered");return(<div><CounterDisplaycount={count1}setCount={setCount1}/><CounterDisplaycount={count2}setCount={setCount2}/></div>);}exportdefaultCounter;
CounterDisplay
is a simple counter component. When a user clicks theIncrement
button, a message logs to the console.
Now, inside theCounter
component, we renderCounterDisplay
twice. Open your console, you will notice that when we click onCounterDisplay1
,CounterDisplay2
also re-renders. We, however, want to re-render only theCounterDisplay
instance whose state has changed.
We would typically optimize this component withuseCallback()
. 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:
functionCounterDisplay(t0){const$=_c(7);// Create a cache array with 7 elementsconst{count,setCount}=t0;console.log("CounterDisplay re-rendered",count);lett1;if($[0]!==count){// Check if count has changedt1=<p>Count:{count}</p>; //Createnewparagraphelement$[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}lett2;if($[2]!==setCount){// Check if setCount function has changedt2=<buttononClick={()=>setCount((c)=>c+1)}>Increment</button>;$[2]=setCount;$[3]=t2;}else{t2=$[3];}lett3;if($[4]!==t1||$[5]!==t2){// Check if either child element has changedt3=(<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}returnt3;}
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 theCounter
component.
Note: We have only shown the compiler code forCounterDisplay
. But a similar optimization will happen inCounter
.
The optimized Counter function
functionCounter(){const$=_c(7);// Create a cache array with 7 elementsconst[count1,setCount1]=useState(0);const[count2,setCount2]=useState(0);console.log("Counter re-rendered");lett0;if($[0]!==count1){// Check if count1 has changedt0=;$[0]=count1;$[1]=t0;}else{t0=$[1];// Reuse cached CounterDisplay element if count1 hasn't changed}lett1;if($[2]!==count2){t1=;$[2]=count2;$[3]=t1;}else{t1=$[3];}lett2;if($[4]!==t0||$[5]!==t1){// Check if either CounterDisplay element has changedt2=({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}returnt2;}
Server Components
Server components are the latest iteration in React's attempt to load content better. So far, we haveclient-side rendering (CSR),server-side rendering (SSR), andstatic site generation (SSG)
Leading up to Server Components
In CSR, JavaScript modules load, andHTML
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. TheHTML
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 theHTML
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 theHTML
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)
For further actions, you may consider blocking this person and/orreporting abuse