Skip to content

Commit ec5a08a

Browse files
committed
Add suspense example
1 parent 666d776 commit ec5a08a

4 files changed

Lines changed: 267 additions & 32 deletions

File tree

.storybook/preview.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ export const parameters = {
2828
]
2929
}
3030
},
31+
react: {
32+
rootOptions: {
33+
// Prevent errors caught in error boundaries from showing Parcel's error overlay.
34+
onCaughtError() {}
35+
}
36+
},
3137
layout: 'fullscreen',
3238
// Stops infinite loop memory crash when saving CSF stories https://github.com/storybookjs/storybook/issues/12747#issuecomment-1151803506
3339
docs: {

packages/react-aria-components/src/ListBox.tsx

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ import {ListState, UNSTABLE_useFilteredListState, useListState} from 'react-stat
5555
import {LoadMoreSentinelProps, useLoadMoreSentinel} from 'react-aria/private/utils/useLoadMoreSentinel';
5656
import {mergeProps} from 'react-aria/mergeProps';
5757
import {Node, Orientation, SelectionBehavior} from '@react-types/shared';
58-
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
58+
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react';
5959
import {SelectableCollectionContext, SelectableCollectionContextValue} from './Autocomplete';
6060
import {SelectionIndicatorContext} from './SelectionIndicator';
6161
import {SeparatorContext} from './Separator';
@@ -572,11 +572,24 @@ export const ListBoxLoadMoreItem = createLeafComponent(LoaderNode, function List
572572
scrollOffset
573573
}), [onLoadMore, scrollOffset, state?.collection]);
574574
useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef);
575+
576+
return (
577+
<>
578+
{/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */}
579+
{/* @ts-ignore - compatibility with React < 19 */}
580+
<div style={{position: 'relative', width: 0, height: 0}} inert={inertValue(true)} >
581+
<div data-testid="loadMoreSentinel" ref={sentinelRef} style={{position: 'absolute', height: 1, width: 1}} />
582+
</div>
583+
{isLoading && <ListBoxLoader {...otherProps} ref={ref}>{item.rendered}</ListBoxLoader>}
584+
</>
585+
);
586+
});
587+
588+
const ListBoxLoader = createLeafComponent(LoaderNode, function ListBoxLoader(props: HTMLAttributes<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>) {
575589
let renderProps = useRenderProps({
576-
...otherProps,
590+
...props,
577591
id: undefined,
578-
children: item.rendered,
579-
defaultClassName: 'react-aria-ListBoxLoadingIndicator',
592+
defaultClassName: 'react-aria-ListBoxLoadMoreItem',
580593
values: undefined
581594
});
582595

@@ -589,22 +602,73 @@ export const ListBoxLoadMoreItem = createLeafComponent(LoaderNode, function List
589602
};
590603

591604
return (
592-
<>
593-
{/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */}
594-
{/* @ts-ignore - compatibility with React < 19 */}
595-
<div style={{position: 'relative', width: 0, height: 0}} inert={inertValue(true)} >
596-
<div data-testid="loadMoreSentinel" ref={sentinelRef} style={{position: 'absolute', height: 1, width: 1}} />
597-
</div>
598-
{isLoading && renderProps.children && (
599-
<dom.div
600-
{...mergeProps(filterDOMProps(props, {global: true}), optionProps)}
601-
{...renderProps}
602-
// aria-selected isn't needed here since this option is not selectable.
603-
role="option"
604-
ref={ref as ForwardedRef<HTMLDivElement>}>
605-
{renderProps.children}
606-
</dom.div>
607-
)}
608-
</>
605+
<dom.div
606+
{...mergeProps(filterDOMProps(props, {global: true}), optionProps)}
607+
{...renderProps}
608+
// aria-selected isn't needed here since this option is not selectable.
609+
role="option"
610+
ref={ref}>
611+
{renderProps.children}
612+
</dom.div>
609613
);
610614
});
615+
616+
export interface ListBoxSuspenseProps {
617+
fallback: ReactNode,
618+
renderError?: (error: unknown) => ReactNode,
619+
loading?: 'eager' | 'lazy',
620+
children: ReactNode
621+
}
622+
623+
export function ListBoxSuspense({fallback, renderError, loading, children}: ListBoxSuspenseProps) {
624+
let [isVisible, setVisible] = useState(false);
625+
626+
if (loading === 'lazy' && !isVisible) {
627+
return <ListBoxLoadMoreItem onLoadMore={() => setVisible(true)} />;
628+
}
629+
630+
let res = (
631+
<React.Suspense fallback={<ListBoxLoader>{fallback}</ListBoxLoader>}>
632+
{children}
633+
</React.Suspense>
634+
);
635+
636+
if (renderError) {
637+
res = (
638+
<ErrorBoundary renderError={err => <ListBoxLoader>{renderError(err)}</ListBoxLoader>}>
639+
{res}
640+
</ErrorBoundary>
641+
);
642+
}
643+
644+
return res;
645+
}
646+
647+
interface ErrorBoundaryProps {
648+
children: ReactNode,
649+
renderError: (error: unknown) => ReactNode
650+
}
651+
652+
interface ErrorBoundaryState {
653+
error: unknown | null
654+
}
655+
656+
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
657+
state = {
658+
error: null
659+
};
660+
661+
static getDerivedStateFromError(error: unknown) {
662+
return {error};
663+
}
664+
665+
componentDidCatch(): void {}
666+
667+
render(): ReactNode {
668+
if (this.state.error) {
669+
return this.props.renderError(this.state.error);
670+
}
671+
672+
return this.props.children;
673+
}
674+
}

packages/react-aria-components/stories/ListBox.stories.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {ListBox, ListBoxItem, ListBoxProps, ListBoxSection} from '../src/ListBox
1919
import {ListBoxLoadMoreItem} from '../src/ListBox';
2020
import {LoadingSpinner, MyHeader, MyListBoxItem} from './utils';
2121
import {Meta, StoryFn, StoryObj} from '@storybook/react';
22+
import {ListBoxSuspense as RACListBoxSuspense} from '../src/ListBox';
2223
import React, {JSX, useState} from 'react';
2324
import {Separator} from '../src/Separator';
2425
import styles from '../example/index.css';
@@ -778,6 +779,107 @@ export const AsyncListBoxVirtualized: StoryFn<typeof AsyncListBoxRender> = (args
778779
);
779780
};
780781

782+
export const ListBoxSuspense: StoryObj<typeof ListBoxSuspenseRender> = {
783+
render: (args) => <ListBoxSuspenseRender {...args} />,
784+
args: {
785+
orientation: 'vertical',
786+
delay: 50,
787+
error: false
788+
},
789+
argTypes: {
790+
orientation: {
791+
control: 'radio',
792+
options: ['horizontal', 'vertical']
793+
}
794+
}
795+
};
796+
797+
function ListBoxSuspenseRender(args: {delay: number, error: boolean, orientation: 'horizontal' | 'vertical'}): JSX.Element {
798+
return (
799+
<ListBox
800+
{...args}
801+
style={{
802+
height: args.orientation === 'horizontal' ? 'fit-content' : 400,
803+
width: args.orientation === 'horizontal' ? 400 : 200,
804+
overflow: 'auto'
805+
}}
806+
aria-label="async listbox">
807+
<RACListBoxSuspense
808+
fallback={<LoadingSpinner style={{height: 20, width: 20, position: 'unset'}} />}
809+
renderError={err => String(err)}>
810+
<Page url="https://pokeapi.co/api/v2/pokemon" delay={args.delay} error={args.error} />
811+
</RACListBoxSuspense>
812+
</ListBox>
813+
);
814+
}
815+
816+
function Page({url, delay, error}: {url: string, delay: number, error: boolean}) {
817+
let promise = loadCached<{results: Character[], next: string | null}>(url, delay, error);
818+
let {results, next} = React.use(promise);
819+
820+
return (
821+
<>
822+
<Collection items={results}>
823+
{item => (
824+
<MyListBoxItem
825+
style={{
826+
minHeight: 50,
827+
minWidth: 200,
828+
backgroundColor: 'lightgrey',
829+
border: '1px solid black',
830+
boxSizing: 'border-box'
831+
}}
832+
id={item.name}>
833+
{item.name}
834+
</MyListBoxItem>
835+
)}
836+
</Collection>
837+
{next && (
838+
<RACListBoxSuspense
839+
loading="lazy"
840+
fallback={
841+
<div
842+
style={{
843+
height: 30,
844+
width: '100%',
845+
flexShrink: 0,
846+
display: 'flex',
847+
alignItems: 'center',
848+
justifyContent: 'center'
849+
}}>
850+
<LoadingSpinner style={{height: 20, width: 20, position: 'unset'}} />
851+
</div>
852+
}
853+
renderError={(err) => String(err)}>
854+
<Page url={next} delay={delay} error={error} />
855+
</RACListBoxSuspense>
856+
)}
857+
</>
858+
);
859+
}
860+
861+
const cache = new Map();
862+
863+
async function load(url: string, delay: number, error: boolean) {
864+
await new Promise(resolve => setTimeout(resolve, delay));
865+
if (error) {
866+
throw 'Error loading pokemon!';
867+
}
868+
let res = await fetch(url);
869+
let json = await res.json();
870+
return json;
871+
}
872+
873+
function loadCached<T>(url: string, delay: number, error: boolean): Promise<T> {
874+
let key = `${url}:${error}`;
875+
let res = cache.get(key);
876+
if (!res) {
877+
res = load(url, delay, error);
878+
cache.set(key, res);
879+
}
880+
return res;
881+
}
882+
781883
export const ListBoxScrollMargin: ListBoxStory = (args) => {
782884
let items: {id: number, name: string, description: string}[] = [];
783885
for (let i = 0; i < 100; i++) {

rfcs/2026-async-react.md

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ In this RFC, we propose adding support for React's action prop pattern to React
1919

2020
## Motivation
2121

22-
At React Conf 2025, the React core team [presented](https://www.youtube.com/watch?v=B_2E96URooA) their vision of "Async React". Using features introduced in React 19 such as [useTransition](https://react.dev/reference/react/useTransition), [useOptimistic](https://react.dev/reference/react/useOptimistic), and [Suspense](https://react.dev/reference/react/Suspense) for data fetching, React can now coordinate loading states across an entire app, and reduce the amount of code needed to handle data loading edge cases. This improves the user experience by making loading/saving states in-line with the component that triggered the update.
22+
At React Conf 2025, the React core team [presented](https://www.youtube.com/watch?v=B_2E96URooA) their vision of "Async React". Using features introduced in React 19 such as [useTransition](https://react.dev/reference/react/useTransition), [useOptimistic](https://react.dev/reference/react/useOptimistic), and [Suspense](https://react.dev/reference/react/Suspense) for data fetching, React can now coordinate pending states across an entire app, and reduce the amount of code needed to handle data fetching edge cases. This improves the user experience by making loading/saving states in-line with the component that triggered the update.
2323

24-
While these React hooks are usable today, they require some boilerplate to set up. This can be simplified by introducing the [action prop](https://react.dev/reference/react/useTransition#exposing-action-props-from-components) pattern. By convention, action props are automatically wrapped in React's `startTransition` function and may include a pending state within the component that triggered them. This way the application doesn't need to handle these states themselves since it's handled by the component library.
24+
While these React features are usable today, they require some boilerplate to set up. This can be simplified by introducing the [action prop](https://react.dev/reference/react/useTransition#exposing-action-props-from-components) pattern. By convention, action props are automatically wrapped in React's `startTransition` function and may include a pending state within the component that triggered them. This way the application doesn't need to handle these states themselves since it's handled by the component library.
2525

2626
## Detailed Design
2727

28-
This RFC proposes adding support for action props directly to React Aria Components. While it's possible to introduce these at a higher level (e.g. in a design system), pending states have accessibility requirements to ensure clear announcements for screen readers, focus management, etc. In addition, multiple design systems can benefit from handling pending states at a lower level layer.
28+
This RFC proposes two new features: built-in action props, and improved support for data fetching with Suspense. While it's possible to introduce these at a higher level (e.g. in a design system), pending and error states have accessibility requirements to ensure clear announcements for screen readers, focus management, etc. In addition, multiple design systems can benefit from handling pending states at a lower level layer.
29+
30+
### Action props
2931

3032
Action props will correspond to events, either using the `action` name for simple actions (e.g. Button) or the `Action` suffix (e.g. `changeAction`). These accept an `async` function, which is called within React's `startTransition` function. Each component supporting actions will expose an `isPending` render prop and `data-pending` DOM attribute. This will be used to render a `<ProgressBar>`, associated with the element via ARIA attributes. We will also handle announcing the state change via an ARIA live region.
3133

@@ -35,7 +37,7 @@ To implement this, we can create a new hook that wraps `useControlledState` and
3537

3638
We will also catch errors that are thrown by actions and expose them as an `actionError` render prop, or via the `FieldError` component, enabling [in-line contextual error UIs](https://x.com/devongovett/status/1989788456751697958). This will help reduce over-reliance on toasts as a catch-all way of handling errors in applications by making inline errors just as easy to implement.
3739

38-
All together, this significantly simplifies the implementation of loading states and error handling for component libraries and applications. Simply render a `<ProgressBar>` when `isPending` is true, add an async function as an action prop, and React Aria handles the rest.
40+
All together, this significantly simplifies the implementation of pending states and error handling for component libraries and applications. Simply render a `<ProgressBar>` when `isPending` is true, add an async function as an action prop, and React Aria handles the rest.
3941

4042
Here's a potential list of components that could support actions:
4143

@@ -57,12 +59,13 @@ Here's a potential list of components that could support actions:
5759
* Select - `changeAction`
5860
* Slider - `changeAction`
5961
* Switch - `changeAction` (only when using `SwitchField`, introduced in [#9877](https://github.com/adobe/react-spectrum/pull/9877))
62+
* Table – `sortAction` (progress should show within the sorted column header)
6063
* Tabs - `selectionAction`
6164
* TextField - `changeAction`
6265
* TimeField - `changeAction`
6366
* ToggleButton - `changeAction`
6467

65-
### Examples
68+
Below are some examples of some common patterns.
6669

6770
#### Pending button
6871

@@ -211,12 +214,6 @@ function App() {
211214

212215
When an error is thrown in a form's `submitAction`, it will be available via the `actionError` render prop. This can be displayed to the user by rendering an `<Alert>`, which will be focused and announced by screen readers. For field-level errors (e.g. server validation), a special error object compatible with [Standard Schema](https://standardschema.dev/schema) could be supported, allowing these errors to be automatically propagated to the correct fields (as we support via the `validationErrors` prop today).
213216

214-
**Note**: This proposes a separate `submitAction` prop rather than overloading the existing `action` prop supported by React. `submitAction` has a few differences from `action`:
215-
216-
* Errors thrown during the action are caught and passed to the `actionError` render prop.
217-
* The pending state is automatically passed to the form's submit button. Alternatively we could use React's [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus) hook for that, but this has [bugs](https://github.com/facebook/react/issues/30368) at the moment.
218-
* The form is not automatically reset after the action completes. This is a [controversial](https://github.com/facebook/react/issues/29034) behavior that is often unwanted (e.g. when errors occur). If a reset is desired, it can be triggered manually via `ReactDOM.requestFormReset`.
219-
220217
```tsx
221218
function App() {
222219
return (
@@ -252,6 +249,72 @@ function App() {
252249
}
253250
```
254251

252+
**Note**: This proposes a separate `submitAction` prop rather than overloading the existing `action` prop supported by React. `submitAction` has a few differences from `action`:
253+
254+
* Errors thrown during the action are caught and passed to the `actionError` render prop.
255+
* The pending state is automatically passed to the form's submit button. Alternatively we could use React's [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus) hook for that, but this has [bugs](https://github.com/facebook/react/issues/30368) at the moment.
256+
* The form is not automatically reset after the action completes. This is a [controversial](https://github.com/facebook/react/issues/29034) behavior that is often unwanted (e.g. when errors occur). If a reset is desired, it can be triggered manually via `ReactDOM.requestFormReset`.
257+
258+
### Suspense
259+
260+
Today, we support initial loading states in our collection components through `renderEmptyState` with externally controlled state management. Infinite loading is done via collection-specific components, e.g. `ListBoxLoadMoreItem` which trigger their `onLoadMore` callback when scrolled into view. State management is entirely left to external data fetching libraries (e.g. our `useAsyncList` hook).
261+
262+
With Suspense, we can simplify this by building loading states into our collection components. Here's what an infinite loading ListBox could look like:
263+
264+
```tsx
265+
function Example() {
266+
return (
267+
<ListBox>
268+
<ListBoxSuspense
269+
// Initial loading state
270+
fallback={<ProgressCircle />}
271+
renderError={error => `Error loading data: ${error}`}>
272+
<Page url="https://pokeapi.co/api/v2/pokemon" />
273+
</ListBoxSuspense>
274+
</ListBox>
275+
);
276+
}
277+
278+
function Page({url}) {
279+
// Use a Suspense-compatible data fetching library to get a cached promise for the url.
280+
let promise = fetchCached(url);
281+
let {results, next} = React.use(promise);
282+
283+
// After the data loads, render the items.
284+
return (
285+
<>
286+
<Collection items={results}>
287+
{item => <ListBoxItem>{item.name}</ListBoxItem>}
288+
</Collection>
289+
{next && (
290+
// Lazily render the next page recursively.
291+
<ListBoxSuspense
292+
loading="lazy"
293+
fallback={<ProgressCircle />}
294+
renderError={error => `Error loading data: ${error}`}>
295+
<Page url={next} />
296+
</ListBoxSuspense>
297+
)}
298+
</>
299+
);
300+
}
301+
```
302+
303+
In this example, `ListBoxSuspense` works like `React.Suspense` but with a few additions:
304+
305+
* It supports `loading="lazy"`, which renders its children only once it is near the viewport.
306+
* It includes an error boundary when `renderError` is provided.
307+
* It wraps its fallback/error in an appropriate element to ensure the accessibility tree is valid (e.g. `<div role="option">`).
308+
309+
TBD whether a separate component per collection is necessary, or if we could somehow ensure the correct accessibility wrapper is added automatically.
310+
311+
There are several benefits to using Suspense for data fetching instead of an external hook:
312+
313+
* It is declarative. External loading and error states must be manually passed around and rendered. Suspense allows design systems and component libraries to build in these states automatically, no matter the data source.
314+
* It is composable. Different sections within a collection can load from different data sources. Apps can decide whether to make those have a single loading state or separate ones.
315+
* It will wait for nested parts of the UI to become ready. For example, if each list item contained an image, the list could wait to display the images together instead of popping in one by one.
316+
* It supports streaming data from the server with React Server Components, enabling fetching to start earlier.
317+
255318
## Documentation
256319

257320
We'll add new examples to our documentation showing how to use action props, and add pending states to components in our starter kits.

0 commit comments

Comments
 (0)