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.
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:
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.
Feature: Automatic Batching
Main entry: https://github.com/reactwg/react-18/discussions/21
In React 17
Automatic batching in browser event (click, input, ...) handlers.
In react 18
Automatic batching works even in external callbacks: timeout, promise, ...
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
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):
- Lazy component is replaced with a "hole".
<Panel>
is partially rendered,display: none
is added to hide its content,<Panel>
's effects are fired.
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.
Real streaming HTML
Consider the following component:
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.
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.
Prioritize hydration
Consider the following component:
<Layout>
<NavBar />
<Suspense fallback={<Spinner />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
React causes the interaction during the capture phase.
Library upgrade guides:
<script>
: https://github.com/reactwg/react-18/discussions/114<style>
: https://github.com/reactwg/react-18/discussions/110<link rel="stylesheet">
: https://github.com/reactwg/react-18/discussions/108
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
useId
: https://github.com/reactwg/react-18/discussions/111useTransition
useDeferredValue
: https://github.com/reactwg/react-18/discussions/129useSyncExternalStore
: https://github.com/reactwg/react-18/discussions/86
RFC: https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md (permanent link)useInsertionEffect
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.