React 18 - a concise yet comprehensive summary

React 18 - a concise yet comprehensive summary

Main entry: https://github.com/reactjs/rfcs/blob/react-18/text/0000-react-18.md (permanent link)

To install: https://github.com/reactwg/react-18/discussions/9

Root API

Main entry: https://github.com/reactwg/react-18/discussions/5

Legacy root exists for an easier upgrade to react 18 and for comparison between old and new behavior.

import * as ReactDOM from 'react-dom';
import App from 'App';

const container = document.getElementById('app');

// ########With render############
// Initial render.
// In the legacy root API, you could pass render a callback that is called after the component is rendered or updated:
ReactDOM.render(container, <App tab="home" />);

// During an update, React would access
// the root of the DOM element.
ReactDOM.render(<App tab="profile" />, container);


// #######With hydration###########
// Render with hydration.
ReactDOM.hydrate(<App tab="home" />, container);
react 17 legacy root
import * as ReactDOMClient from 'react-dom/client';
import App from 'App';

const container = document.getElementById('app');

// Create a root.
const root = ReactDOMClient.createRoot(container);

// ########With render############
// Initial render: Render an element to the root.
root.render(<App tab="home" />);

// During an update, there's no need to pass the container again.
root.render(<App tab="profile" />);

// #######With hydration###########
// Create *and* render a root with hydration.
const root = ReactDOMClient.hydrateRoot(container, <App tab="home" />);
// Unlike with createRoot, you don't need a separate root.render() call here

// You can later update it.
root.render(<App tab="profile" />);
react 18 new root

New streaming API

  • New: renderToPipeableStream. Fully support suspense and true streaming.
  • Deprecated: renderToNodeStream.
  • Legacy: renderToString.  Limited suspense support because of synchronity.

Main discussion: https://github.com/reactwg/react-18/discussions/22

Render callback

In the legacy root API, you could pass render a callback that is called after the component is rendered or updated:

import * as ReactDOM from 'react-dom';
import App from 'App';

const container = document.getElementById('app');

ReactDOM.render(container, <App tab="home" />, function() {
  // Called after inital render or any update.
  console.log('rendered').
});
render callback is called after the component is rendered or updated

With partial hydration and progressive SSR, the timing for this callback would not match what the user expects. To avoid confusion moving forward, we recommend using requestIdleCallback, setTimeout, or a ref callback on the root.

import * as ReactDOMClient from 'react-dom/client';

function App({ callback }) {
  // Callback will be called when the div is first created.
  return (
    <div ref={callback}>
      <h1>Hello World</h1>
    </div>
  );
}

const rootElement = document.getElementById("root");

const root = ReactDOMClient.createRoot(rootElement);
root.render(<App callback={() => console.log("renderered")} />);
render callback by passing as props

Feature: Automatic Batching

Main entry: https://github.com/reactwg/react-18/discussions/21

In React 17

Automatic batching in browser event (click, input, ...) handlers.

  function handleClick() {
    setCount(c => c + 1); // Does not re-render yet
    setFlag(f => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }
  <button onClick={handleClick}>Next</button>
Demo react 17: automatic batching works
  function handleClick() {
    fetchSomething().then(() => {
      // React 17 and earlier does NOT batch these because
      // they run *after* the event in a callback, not *during* it
      setCount(c => c + 1); // Causes a re-render
      setFlag(f => !f); // Causes a re-render
    });
  }
Demo react 17: automatic batching does not work

In react 18

Automatic batching works even in external callbacks: timeout, promise, ...

  function handleClick() {
    fetchSomething().then(() => {
      // React 18 and later DOES batch these:
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    });
  }
Demo react 18: automatic batching works with new root tree

Demo react 18: automatic batching does not work with legacy root tree.

When does batch update happen?

Note: React only batches updates when it’s generally safe to do. For example, React ensures that for each user-initiated event like a click or a keypress, the DOM is fully updated before the next event. This ensures, for example, that a form that disables on submit can’t be submitted twice.

To force re-render

Use flushSync from react-dom.

import { flushSync } from 'react-dom'; // Note: react-dom, not react

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // React has updated the DOM by now
  flushSync(() => {
    setFlag(f => !f);
  });
  // React has updated the DOM by now
}

Breaking change for class component

handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};
Before react 18
handleClick = () => {
  setTimeout(() => {
    this.setState(({ count }) => ({ count: count + 1 }));

    // { count: 0, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};
After react 18
handleClick = () => {
  setTimeout(() => {
    ReactDOM.flushSync(() => {
      this.setState(({ count }) => ({ count: count + 1 }));
    });

    // { count: 1, flag: false }
    console.log(this.state);

    this.setState(({ flag }) => ({ flag: !flag }));
  });
};
Enable the legacy behavior manually

Feature: Transition

Main entry: https://github.com/reactwg/react-18/discussions/41

Two types of updates:

  • Urgent update: reflects direct interaction: typing, clicking, pressing, ...
    Urgent update is used by default (for compatibility).
  • Transition update: other updates.
    Most updates are conceptually transition updates.

To mark an update as transition, use startTransition.

import { startTransition } from 'react';


// Urgent: Show what was typed
setInputValue(input);

// Mark any state updates inside as transitions
startTransition(() => {
  // Transition: Show the results
  setSearchQuery(input);
});
import { useTransition } from 'react';


const [isPending, startTransition] = useTransition();

return <>
    {isPending && <Spinner />}
</>

Real-world example of transition.

Suspense in react 18

Main entry: https://github.com/reactjs/rfcs/blob/suspense-18/text/0213-suspense-in-react-18.md (permanent link)

Behavior change

Consider this example

<div>
  <Suspense fallback={<Spinner />}>
    <Panel>
      <Comments />
    </Panel>
  </Suspense>
</div>

Behavior before react 18 (Legacy Suspense):

Behavior in react 18 (Concurrent Suspense, discussion):

  • <Panel> is completely removed. No effect is fired.
  • When the lazy component becomes ready, <Panel> is initially and fully rendered.

Reason for keeping the old behavior until react 18: is to keep backward compatibility with UNSAFE_componentWillMount. From react 18, code with UNSAFE_componentWillMount, <Suspense> might have a memory leak.

Fully supported in SSR

Main entry: https://github.com/reactwg/react-18/discussions/37

The goal: partially load and hydrate each component in the react tree.

Solution: break the waterfall of "fetch data (server) → render to HTML (server) → load code (client) → hydrate (client)" part for each suspense component.

With the new architecture, Suspense will enable

  • Real streaming HTML: the idea is to inject inline <script> and modify the previous part of the HTML.
  • Selective hydration: it is now possible to hydrate part components independently. Further, react now can prioritize the hydration of component gets interaction from users.

Demo link

Real streaming HTML

Consider the following component:

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>
Note: there is <Suspense> without lazy()

The server sends <Spinner/> placeholder.

The corresponding HTML is

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

In the next data chunk, the server sends an inline <script> to replace the suspended component even before the hydration happens.

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

Selective hydration

Hydration now happens individually.

Hydration on other parts happens before the suspended component code is loaded

Even more, hydration can happen before the suspended HTML is loaded (by injecting the inline <script>), for instance: when JS code loads earlier than HTML.

Hydration happens even before all HTML is loaded

Prioritize hydration

Consider the following component:

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>
The SideBar gets hydrating
Before SideBar is fully hydrated, the Comments component gets interacted

React causes the interaction during the capture phase.

The interacted Comments component is prioritized

Library upgrade guides:

Last but not least, because the suspended HTML requires javascript to be injected,  you might consider dynamic rendering for better SEO support.

Integration with startTransition

Consider the code

function handleClick() {
  startTransition(() => {
    setTab('comments');
  });
}

<Suspense fallback={<Spinner />}>
  {tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>

<Photos/> is fully loaded, <Comments/> suspends. When changing tab from photos to comments, if we wrap the setTab() in startTransition(), the old UI of <Photos/> will keep showing until the <Comments/> component is ready.

Using useTransition can provide immediate feedback.

const [isPending, startTransition] = useTransition();

function handleClick() {
  startTransition(() => {
    setTab('comments');
  });
}

<Suspense fallback={<Spinner />}>
  <div style={{ opacity: isPending ? 0.8 : 1 }}>
    {tab === 'photos' ? <Photos /> : <Comments />}
  </div>
</Suspense>

Layout effects re-run when content reappears

function handleClick() {
  setTab('comments');
}

<Suspense fallback={<Spinner />}>
  <AutoSize>
    {tab === 'photos' ? <Photos /> : <Comments />}
  </AutoSize>
</Suspense>

Main entry: https://github.com/reactwg/react-18/discussions/31

To solve this, this RFC proposes to run layout effects only on hide and show. Concretely, when React needs to hide the Suspense content, it will run the "cleanup" of the layout effects inside of that tree. When React is ready to show the Suspense content again, it will run the layout effects in that tree similar to when the tree first appeared. This way, as long as components like AutoSize contain layout-related logic in layout effects, they would usually "just work" with Suspense.

StrictMode adds reusable state

Main entry: https://github.com/reactwg/react-18/discussions/19

Related posts:

From react 18, in dev environment (the development build), StrictMode double-invokes effects (mount -> unmount -> mount). It also reuses the state in the next mount effect call.

New hooks

useSyncExternalStore

Compared to the previous useMutableSource, the new useSyncExternalStore hook:

  • Updates triggered by a store change will always be synchronous, even when wrapped in startTransition
  • In exchange, updates triggered by built-in React state will never deopt by showing a bad fallback, even if it reads from a store during the same render

Concurrent rendering

Main entry: https://github.com/reactwg/react-18/discussions/70

components and custom Hooks which don’t access external mutable data while rendering and only pass information using React props, state or context, shouldn’t have issues because React handles those concurrently natively.

What is tearing?: https://github.com/reactwg/react-18/discussions/69

3 levels of support:

  • make it work: with useSubscription hook. Tearing will happen but will be fixed after re-rendering. Users will face a flashing effect.
  • make it right: with useSyncExternalStore hook. A store change while concurrent rendering happens will cause asynchronous rendering (i.e., de-opt).
  • make it fast: use react state. For example, use react context with immutable snapshots.

Other changes

  • Components can now return undefined.
  • No warning about setState on an unmounted component (PR).
  • Promise, Symbol, Object.assign polyfills are removed.