React v18

React v18

React is one of the most popular frontend JavaScript library right now and the dev team behind this library is constantly updating and changing the library to keep up with the latest developments in web dev space and also to keep up with other frontend JavaScript libraries similar to React.

Recently React v18 has introduced a lot of new features that were previously unavailable. The main purpose of these new features is to improve user experience by making the application faster and more responsive. In this article I want to talk about some of the major updates introduced in the latest React version.


1. Concurrent features: Concurrent features is the single most important update introduced in React v18. The main purpose of this newly introduced feature is to produce multiple versions of the UI at the same time by handling multiple state updates concurrently or "in parallel".

Previously (or before React v18) whenever multiple state updates occur in a component, React treated those updates one by one in a sequential manner. There was a single main UI thread and all the UI updates are executed sequentially on that thread.

But "Concurrent mode" or "Concurrent features" is a fundamental rework to React's core rendering model. Instead of treating all the state changes in a sequential manner, React can produce multiple versions of the same UI concurrently. React may start a UI update, pause in the middle for a while to render more important updates, and then resume from where it left off.

Basically with the concurrent features, react is introducing two types of UI updates: 1. Urgent updates and 2. Transition updates.

Urgent updates mean those updates that need to occur immediately, otherwise the user experience will be hampered. For example, when user is typing in an input field or a search field, the user expects that each keystroke he / she makes will happen immediately with no latency or delay.

On the other hand, transition updates mean less prioritized updates that need not happen immediately. For example when the user is typing in search field to search in a list, there will be a slight unnoticeable delay between when the user finishes typing and when the list gets updated with only the filtered results. This delay is unnoticeable and often expected.

To enable concurrent features in a react project, we have to make some code changes in index.js or index.ts file of our project which is basically the entry point of any react application.

import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);        

Now the index.js file will look like the code above where we are importing ReactDOM from react-dom/client; then we are using the newly introduced createRoot method to create our root component object. And finally we are calling the render method on root object to render our root App component inside index.html file.


2. Suspense: Suspense is another notable update in React. It is basically a wrapper component which lets you display a fallback UI until all the child components and all the data associated with those components have finished loading. The code will look something like this:

// ...

<Suspense fallback={<Loading />}>
  <Dashboard />
</Suspense>

// ...        

Here we are wrapping the <Dashboard /> component inside a <Suspense /> and in the fallback property we are passing <Loading /> component. So this means that a <Loading /> component will be rendered in the UI until the <Dashboard /> component and all the data associated with this component has finished loading. <Suspense /> is great for implementing loading state in an application but it can be used for other purposes as well.


3. useTransition Hook: "useTransition" is a new powerful hook introduced in React v18. The purpose of this hook is to separate or decouple the less important state updates from more important updates. Some updates in the frontend UI are urgent meaning that they need to occur immediately without any delay, otherwise the user experience may get hampered. useTransition decouples these urgent updates from the less important ones. Here is an example code:

import Loading from './Loading';
import  { filterData } from '../common/filterData';

const TabContainer = () => {
    const [search, setSearch] = useState('');
    const [results, setResults] = useState([]);
    const [isPending, startTransition] = useTransition();

    const onSearchChange = (ev) => {
        setSearch(ev.target.value);
        startTransition(() => {
            setResults(filterData());
        });
    };

    return (
        <>
            <input type="text" value={search} onChange={onSearchChange} />
            {isPending && <Loading />}
            {!isPending && (
                <ul>
                    {results.map(it => <li key={it.id}>{it.name}</li>)}
                </ul>
            )}
        </>
    )
};

export default TabContainer;        

In this example, we are providing the user with a search field and based on the search value we are rendering a list of items. We are using the search and results states to manage the search value and the list of items respectively. In this case updating the search value is an urgent update or more important state update because we want to make sure the keystrokes inside the search field is not lagging the UI. At each keypress the user expects the search field to get immediately updated with the latest value. But on the other hand, updating the list of items based on the search value is a transition update or less important update. As long as the user keeps typing we want to show the previous list of items; but as soon as the user finishes his/her typing we want to make sure that the list of items gets updated.

This is where the useTransition hook comes into play. The useTransition hook provides an isPending flag which is a boolean value and a startTransition method which we can also use standalone outside component boundary.

To update the results we are showing to the user, we are wrapping the code inside the startTransition method. This means that React will prioritize the updates outside startTransition which in this case is the setSearch() method. All the updates inside startTransition will get less priority. So the startTransition callback will only get called when the other more important state updates get finished.

As long as the transition updates are not done, the isPending flag will remain true which indicates the transition is ongoing. The isPending flag will become false once all the transition updates get completed. So we can use the isPending flag to show a loading state and once the list of items get updated, the isPending is false and in that case we are rendering the list on the screen.


4. useOptimistic Hook: The "useOptimistic" hook is another newly introduced hook which will let developers optimistically update the UI. When an async action is performed in the background (such as sending a network request to backend), the useOptimistic hook lets us provide an immediate visual feedback to the user by showing the updated result immediately on the screen; although the actual request to the backend server takes some time to get processed. This immediate visual feedback of the result gives a sense of speed and responsiveness to the user. Below is a code example:

import { postLike } from '../common/action';

export const LikeButton = ({count}) => {
    const [optmistic, addOptimistic] = useOptimistic(
        count ?? 0,
        (state) => state + 1
    );

    const like = () => {
        addOptimistic(1);
        postLike();
    };

    return (
        <button onClick={like}>
            ({optmistic})
        </button>
    );
};        

In this example, a like button is implemented which also shows the total count of likes. The useOptimistic hook takes two arguments - the first one is state and the second one is a dispatching function. It returns two values - the optimistic value and also a method addOptimistic which we can call to update the value optimistically.

So when we call the addOptimistic method, the dispatching function which we passed as an argument will get called and it will return a new value based on current state and previous optimistic value.

When user clicks the like button, the onClick method will first call addOptimistic method to immediately update the optimistic value although the actual state is not updated immediately. In the UI, we are showing the optimistic value instead of the state value to give an immediate feedback to the user. Then when we call the postLike method, it will send a request to the backend server to update the like count and after the backend action is successfully done, the actual state gets updated and all the syncing between the optimistic value and state value happens at the background.


5. useDeferredValue Hook: "useDeferredValue" is another awesome hook introduced in React 18 which is quite similar to useTransition hook and almost opposite to useOptimistic hook.

This hook is used to defer re-rendering a part of the UI. It is used to show stale content while fresh content is loading in the background. The useDeferredValue hook is integrated with Suspense. This means that if a background update caused by a new value suspends the UI, the user will not see the suspense fallback. Instead they will see the old deferred value until the data loads. Let me explain it with a code example:

export const App = () => {
  const [query, setQuery] = useState('');
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={query} />
      </Suspense>
    </>
  );
}        

In this simple example, we are rendering a SearchResults component based on the query state which we are passing as a prop. Whenever the user types a value in the search input field, the query value gets updated. Since we have wrapped the SearchResults with a Suspense fallback, the fallback UI will be shown until the data loads in SearchResults component. So whenever the user puts a new value in the search field or types a new character, the Loading... message will be shown for a moment and then the updated search results will be rendered.

If instead of showing the fallback UI every time the search results get updated, we want to show the previous results until the new results get loaded, in that scenario we can apply useDeferredValue hook like the below example:

export const App = () => {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  return (
    <>
      <label>
        Search albums:
        <input value={query} onChange={e => setQuery(e.target.value)} />
      </label>
      <Suspense fallback={<h2>Loading...</h2>}>
        <SearchResults query={deferredQuery} />
      </Suspense>
    </>
  );
}        

This example looks similar to the previous one, but the only difference is that the useDeferredValue hook is introduced and the query is passed to it as an argument. And it gives us back a new deferredQuery value. Instead of directly passing the query value, we are now passing the new deferredQuery value to the SearchResults component's query prop.

So in this case, whenever the user types a new character in the search field and the query state gets updated, the deferredQuery will retain the previous query value and as a result, React will re-render the SearchResults with the previous query value. At the same time, the re-rendering with the new query value will be prepared in the background and when that finishes and the data fully loads, the new and updated SearchResults will be rendered in the UI. So in summary, this means that no suspense fallback will be shown in this case, instead React will just show the previous search results until the new search results get available.


6. useFormState Hook: "useFormState" is another newly introduced hook that helps it easier to manage form state based on the submission action.

The signature of useFormState hook looks like this:

const [state, formAction] = useFormState(fn, initialState, permalink?)        

useFormState takes two mandatory arguments fn and initialState as well as one optional argument permalink. The fn argument is the form action method that gets called when user submits the form. The initialState is the inital state of the form before any submission occurs. The permalink is the page URL where we want to redirect the user after form submission. Most of the time, permalink argument is not required.

import { useState } from 'react';
import { useFormState } from 'react-dom';
import { addToCart } from './actions.js';

function AddToCartForm({itemId, itemTitle}) {
   const [formState, formAction] = useState(addToCart, {});
   
   return (
    <form action={formAction}>
        <h2>{itemTitle}</h2>
        <input type="hidden" name="itemId" value={itemId} />
        <button type="submit">Add to Cart</button>
        {
            formState?.success && (
                <div className="toast">
                    Added to Cart. Your cart now has {formState.cartSize} items.
                </div>
            )
        }
        {
            !formState?.success && (
                <div className="error">
                    Failed to add to cart : {formState.message}
                </div>
            )
        }
    </form>
   )
}

export default function App() {
    return (
        <>
            <AddToCartForm itemId="1" itemTitle="JavaScript : The Definitive Guide" />
            <AddToCartForm itemId="2" itemTitle="JavaScript : The Good Parts" />
        </>
    )
}        

As we can see in the code above, the useFormState hook takes addToCart function as form action method and an empty object as the initial state. It returns two values -> the formState value which will be initially equal to the initial state; and after the user submits the form, the formState will contain the returned value from the last submission. The second value returned is formAction which is the new action method provided by useFormState that has been passed to the form action. Based on the form submission, the formState will get the latest value returned from the action method. So using formState we can show some toast message or error message to the user to indicate whether the form submission is successful or not. That's why useFormState makes it easier for frontend developers to handle form state.

Most of the times, it is recommended to use the useFormState hook with server actions or in other words when we use a server action in a form action method. Because in that case, the useFormState allows developer to progressively enhance the form and the form can be submitted before the JavaScript bundle has been fully loaded on client side. But server action itself is a new functionality introduced in React and I will discuss it later.


7. useFormStatus Hook: "useFormStatus" is another hook introduced in React to make form handling easier and smoother in a react application. This hook does not take any argument and it returns a status object with 4 properties. The signature looks like this:

const { pending, data, method, action } = useFormStatus();        

Here, we have destructured the status object which contains the following 4 properties:

  • pending: a boolean value (either true or false) which indicates whether the form is currently in the submission process or not. It remains true when the form is actively submitting. We can use this property to disable the submit button while a form is submitting.
  • data: This is an object which implements the FormData interface and it represents the data that the user is submitting through the form. If there is no form or the user has not yet submitted, then it will contain null value.
  • method: It is a string value containing either "get" or "post" value. It indicates whether the form is submitting a GET or POST HTTP request. By default a form always submits a GET request but we can specify it using the method prop of the form.
  • action: This property contains a reference to the action method that is passed to the form. If the action prop is not specified in the form or the action contains a URI value, then it will be null.

So as we can see these 4 properties will come in handy when we want to perform various operations in our application based on the form status.

But there is also a caveat in using this hook. The useFormStatus hook only returns the status of a parent <form> element. Or in other words, it will only work inside a component which is itself a child of an html <form> element. The useFormStatus hook will not return status for the <form> located in the same component or its children components.

Here is an example where depending the pending status, we can disable the submit button-

import { useFormStatus } from "react-dom";
import { submitForm } from "./actions.js";

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

function Form({ action }) {
  return (
    <form action={action}>
      <Submit />
    </form>
  );
}

export default function App() {
  return <Form action={submitForm} />;
}        

8. use hook (experimental): The "use" hook is another newly introduced hook and it is a very powerful hook that will replace other hook like useContext or useEffect in the future versions of React. But as of now, it is an experimental feature, so it is not yet included in a stable version of React. So it is not recommended to use it in production code.

The main purpose of this hook is to take a Promise or a Context as a resource and resolve that Promise or Context to return the final resolved value.

const value = use(resource)        

One big advantage of the use hook over other React hooks is that the use hook can be called inside a conditional statement like if or a loop statement like for loop. But other hooks like useContext or useEffect etc. cannot be called inside an if statement or a for loop.

When we pass a promise inside the use hook, the component calling the use hook integrates with suspense and error bounderies. This means that until the promise is resolved, the suspense fallback will be shown in the UI. As soon as the promise gets resolved, the resolved value will be returned by the use hook and the component will replace the suspense fallback.

Similarly, if a promise gets rejected for some reason, the nearest error boundary's fallback will be shown. If we don't want to use the error boundary to handle the rejection, then another way is to apply the catch method on the Promise and return a newly resolved value in response to the error.

The use hook can also be used to get the value from a context. In this case, it acts like the useContext hook. Similar to useContext hook, to get the context value, it will search the component tree above and find out the nearest or closest context provider.


9. The lazy keyword: "lazy" lets you defer loading component's code until it is rendered for the first time.

Normally in React, when we import a component at the top level of a module, we use a static import statement like this:

import Dashboard from './Dashboard.js';        

But with the lazy keyword, we can apply "lazy loading" to a React component. This simply means that the component's code and the associated data will not be loaded until the component is used for the first time somewhere in the code. This is a performance improvement feature introduced in React v18.

const someComponent = lazy(load);        

The lazy function takes a single argument load which is a callback function. This callback function must return a Promise or some other thenable (a Promise like object with a then method). When we render someComponent for the first time, React will call the load callback function and will wait for the returned Promise to resolve. The resolved value's .default property must be a valid React component which will then get rendered in the component tree. If the Promise does not resolve and instead rejects, then React will propagate the rejection message to the nearest Error boundary to handle. One major advantage of lazy is that once the promise gets resolved, both the promise and the resolved component gets cached on client side, so any subsequent rendering of the component will not call the load method; the load method will only be called at the first rendering.

So if we want to import the Dashboard component using the lazy statement then it will look like this:

const Dashboard = lazy(() => import('./Dashboard.js'));        

The important thing to remember is that the Dashboard component must be exported using a default export statement, otherwise it will not work!

Another thing to remember is that the lazy import statement should be put outside component boundary at the top level of the module (where we put the normal import statements).


10. Server Components & Actions: Just like the Concurrent feature, server components & server actions are also some pretty big updates introduced in the React ecosystem. React is pushing idea of server components more and more and advertising it as the "future of React".

Although server components is a React feature, Next.js is the first React framework that fully supports this feature. In React v18, the features of server components and server actions is still experimental.

Basically React has introduced two new directives "use server" and "use client" and both of these directive commands are top level directives meaning that if we use them in any file, they must be put at the top of file before any other code.

In React, every component is by default a "Client Component" if not otherwise specified. This means that every component will be built and compiled on client side and not on server side.

This is because with React, we build a "Single Page Application" (SPA) and the way a single page application works is that the server returns an empty "root" div to client along with a JavaScript bundle and then on client side the whole UI rendering happens with the help of that JavaScript bundle. So the burden of rendering the whole UI is imposed on client or browser side and not on the server side.

But the idea of server components & server actions is challenging this concept of Client Side Rendering (CSR). Instead of rendering each and every component on client side, we can use the "use server" directive to mark a component as a server side component. This means that the component will now get built and computed on the server and only the final HTML output will be shipped to client.

Using server components has some clear benefits over client components:

  • Server components help us reduce the JavaScript bundle size we ship to the client. Since server components are computed on the server, only client components will be rendered on client side, reducing the overall bundle size.
  • In client side, to fetch data and present it on UI, we need to use useState and useEffect hooks to properly implement complex state management in order to properly update the UI. But in server components, we can use simple fetch call to get all the required data and we can return the final HTML output to the client side with all the data embedded within it.
  • Server is usually more resourceful and has more computational power than client. So rendering a component on server is usually a bit faster compared to client side rendering.

Despite all these benefits, server components also has some drawbacks:

  • Server components don't support event handlers, react hooks or any other browser based API. If we want to handle user events or we want to add interactivity to the page, we have to use client components.
  • We can render a client component directly inside a server component and in this case, the client component will first render on the server and it will be rendered on client side a second time to add JavaScript interactivity to it in a process known as hydration. But server components can not be directly embedded inside a client side component; in this case the server component must be passed as a children prop to the client side component.

So the bottom line is that whenever we need some kind of user interactivity in a page, we have to define it as a client component. The pages without any kind of interactivity can be offloaded to server using the "use server" directive on top of the module.

The "use server" directive can also be used to define a "server action". A server action is an async function that can be called from client side code to send a request to the server over the network. All server actions are exposed endpoints to client side code which the client can call to perform some mutations on the server side.

For example, the most common use case of a "server action" is to use it as a form action method. Whenever user submits the form, a request will be sent over the network to the server. The arguments to the server action will be passed as serialized values and the returned response will also be serialized before sending to client. Here is an example implementation:


async function requestUsername(formData) {
  'use server';
  const username = formData.get('username');
  // ...
}

export default function App() {
  return (
    <form action={requestUsername}>
      <input type="text" name="username" />
      <button type="submit">Request</button>
    </form>
  );
}        

Here requestUsername is a server action because it has the use server directive at top. In this case the requestUsername gets the serialized FormData as an argument which will get processed on server side.

Similarly a server action can also be implemented outside a form action method. In that case it is recommended to wrap the server action call inside a Transition as in the below example:

import incrementLike from './actions';
import { useState, useTransition } from 'react';

function LikeButton() {
  const [isPending, startTransition] = useTransition();
  const [likeCount, setLikeCount] = useState(0);

  const onClick = () => {
    startTransition(async () => {
      const currentCount = await incrementLike();
      setLikeCount(currentCount);
    });
  };

  return (
    <>
      <p>Total Likes: {likeCount}</p>
      <button onClick={onClick} disabled={isPending}>Like</button>;
    </>
  );
}        

All these new updates show just how quickly React ecosystem is evolving to improve user experience as well as developer experience. But this is just the beginning, since React version 19 has also been released just one or two months ago! And from what I have learned so far, the version 19 also has some major improvements and updates under the hood. I am still learning what new features they are introducing in React v19. There's still so much left to learn!

要查看或添加评论,请登录

社区洞察

其他会员也浏览了