|
| 1 | +--- |
| 2 | +id: suspense |
| 3 | +title: Suspense and pending UI |
| 4 | +sidebar_label: Suspense and pending UI |
| 5 | +--- |
| 6 | + |
| 7 | +[React Suspense](https://react.dev/reference/react/Suspense) lets you show a fallback - the loading UI shown in place of content that isn't ready yet - while code or data for part of the UI is loading. In this guide, we'll cover how React Navigation works with Suspense and how to handle pending UI during navigation. |
| 8 | + |
| 9 | +## What can suspend |
| 10 | + |
| 11 | +A Suspense boundary shows its fallback when a component inside it suspends while rendering. A component suspends when it's loaded with [`React.lazy`](https://react.dev/reference/react/lazy), reads a cached Promise with [`React.use`](https://react.dev/reference/react/use), or reads data through a framework or library that integrates with Suspense. |
| 12 | + |
| 13 | +Only data read through one of these Suspense-enabled sources can trigger a boundary. Data fetched in an Effect or event handler has to be routed through such a source first. The examples in this guide assume your code or data is loaded this way. |
| 14 | + |
| 15 | +## How navigation behaves with Suspense |
| 16 | + |
| 17 | +A navigation action refers to any action that updates the [navigation state](navigation-state.md), including actions that don't change the visible screen, such as updating params. Navigation actions triggered from a screen through APIs such as [`useNavigation`](use-navigation.md) or [`useLinkProps`](use-link-props.md) run as [React transitions](https://react.dev/reference/react/startTransition) (not to be confused with visual transition animations). |
| 18 | + |
| 19 | +While the navigation transition is pending: |
| 20 | + |
| 21 | +- The current screen stays visible and interactive. |
| 22 | +- Navigation and focus hooks and events still reflect the current screen. |
| 23 | + |
| 24 | +Once the suspended content is ready, the destination appears and the navigator's animation starts. If you start another navigation before the pending one finishes, it takes over immediately, so you always land on the newest destination. |
| 25 | + |
| 26 | +Some navigation actions don't use transitions, such as switching tabs in a [native tab navigator](bottom-tab-navigator.md), the native back action in a [native stack](native-stack-navigator.md), gesture-driven navigations etc. So a suspending destination shows the nearest fallback instead of keeping the current screen visible. |
| 27 | + |
| 28 | +When writing a [custom navigator](custom-navigators.md), actions dispatched through the navigator's `navigation` prop don't automatically run as transitions. You can choose to wrap them in [`startTransition`](https://react.dev/reference/react/startTransition) based on how your navigator works. |
| 29 | + |
| 30 | +## Existing and new Suspense boundaries |
| 31 | + |
| 32 | +During a transition, React keeps already-visible content on screen instead of replacing it with a fallback. When a boundary first appears as part of the destination, it has no previous content to preserve, so it shows its fallback right away while the rest of the destination renders. |
| 33 | + |
| 34 | +This is why a boundary around the navigator behaves differently from one inside a screen. The boundary around the navigator already exists, so it can keep the current screen visible. A new boundary inside the destination has nothing to keep visible, so it shows its loading UI instead. |
| 35 | + |
| 36 | +## Choosing where to place Suspense boundaries |
| 37 | + |
| 38 | +Where you place the nearest Suspense boundary decides what the user sees while content loads. |
| 39 | + |
| 40 | +### Around the navigator |
| 41 | + |
| 42 | +With a boundary around the whole navigator, the current screen stays visible while the destination suspends during a transition. You can use the navigator's [`layout`](navigator.md#layout) to add this boundary: |
| 43 | + |
| 44 | +```js static2dynamic |
| 45 | +const RootStack = createNativeStackNavigator({ |
| 46 | + // highlight-start |
| 47 | + layout: ({ children }) => ( |
| 48 | + <React.Suspense fallback={<LoadingPlaceholder />}> |
| 49 | + {children} |
| 50 | + </React.Suspense> |
| 51 | + ), |
| 52 | + // highlight-end |
| 53 | + screens: { |
| 54 | + Home: HomeScreen, |
| 55 | + Profile: ProfileScreen, |
| 56 | + }, |
| 57 | +}); |
| 58 | +``` |
| 59 | + |
| 60 | +This fallback is still shown during the initial render, when there is no previously displayed screen to keep visible, or if a component suspends because of a state update that isn't wrapped in a transition. |
| 61 | + |
| 62 | +This can be useful for [stack navigators](stack-navigator.md), where you'd keep the current screen visible during navigation and [show pending UI on the current screen](#showing-pending-ui-on-the-current-screen). |
| 63 | + |
| 64 | +It's not recommended for native tab navigators, where switching tabs by tapping the native tab bar doesn't run as a transition, so the fallback would hide the tab bar if a screen suspends. |
| 65 | + |
| 66 | +### Around each screen |
| 67 | + |
| 68 | +With a boundary around each screen, the destination appears immediately with its fallback instead of keeping the current screen visible. You can use the [`screenLayout`](navigator.md#screen-layout) prop to add a boundary to every screen in a navigator, or a screen's [`layout`](screen.md#layout) to add a boundary to just that screen: |
| 69 | + |
| 70 | +```js static2dynamic |
| 71 | +const RootStack = createNativeStackNavigator({ |
| 72 | + // highlight-start |
| 73 | + screenLayout: ({ children }) => ( |
| 74 | + <React.Suspense fallback={<LoadingPlaceholder />}> |
| 75 | + {children} |
| 76 | + </React.Suspense> |
| 77 | + ), |
| 78 | + // highlight-end |
| 79 | + screens: { |
| 80 | + Home: HomeScreen, |
| 81 | + Profile: ProfileScreen, |
| 82 | + }, |
| 83 | +}); |
| 84 | +``` |
| 85 | + |
| 86 | +This is ideal for tab and [drawer navigators](drawer-navigator.md), as it keeps the tab bar or drawer visible and shows the loading UI only in the screen's area. |
| 87 | + |
| 88 | +### Around a section inside a screen |
| 89 | + |
| 90 | +You don't always need to wait for the whole screen. If only specific parts of it suspend, you can wrap just that part in a boundary and let the rest of the screen render normally: |
| 91 | + |
| 92 | +```js |
| 93 | +function ProfileScreen() { |
| 94 | + return ( |
| 95 | + <> |
| 96 | + <ProfileHeader /> |
| 97 | + // highlight-next-line |
| 98 | + <React.Suspense fallback={<FeedSkeleton />}> |
| 99 | + <ProfileFeed /> |
| 100 | + </React.Suspense> |
| 101 | + </> |
| 102 | + ); |
| 103 | +} |
| 104 | +``` |
| 105 | + |
| 106 | +In this example, `ProfileHeader` appears immediately while `FeedSkeleton` is shown in place of `ProfileFeed`. Once the feed is ready, it replaces the skeleton without affecting the header. |
| 107 | + |
| 108 | +We recommend putting content that should appear together under the same boundary. Avoid adding separate boundaries around every component. |
| 109 | + |
| 110 | +## Showing pending UI on the current screen |
| 111 | + |
| 112 | +When the current screen stays visible after a navigation that suspends, it may not be obvious that tapping a button did anything. Showing a pending state on that button makes it clear that navigation is in progress. |
| 113 | + |
| 114 | +You can use the [`useTransition`](https://react.dev/reference/react/useTransition) hook to show a loading indicator while the navigation transition is pending. We recommend encapsulating this in a reusable button component so that each control manages its own transition, and the loading state of one control doesn't affect the others. |
| 115 | + |
| 116 | +For example, a button that navigates using [`useLinkProps`](use-link-props.md): |
| 117 | + |
| 118 | +```js |
| 119 | +function LinkButton({ in: parent, screen, params, children }) { |
| 120 | + const { onPress, ...rest } = useLinkProps({ in: parent, screen, params }); |
| 121 | + |
| 122 | + // highlight-start |
| 123 | + const [isPending, startTransition] = React.useTransition(); |
| 124 | + const isPendingDeferred = React.useDeferredValue(isPending); |
| 125 | + // highlight-end |
| 126 | + |
| 127 | + return ( |
| 128 | + <MyButton |
| 129 | + {...rest} |
| 130 | + // highlight-next-line |
| 131 | + loading={isPending && isPendingDeferred} |
| 132 | + onPress={(e) => { |
| 133 | + // highlight-start |
| 134 | + startTransition(() => { |
| 135 | + onPress(e); |
| 136 | + }); |
| 137 | + // highlight-end |
| 138 | + }} |
| 139 | + > |
| 140 | + {children} |
| 141 | + </MyButton> |
| 142 | + ); |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +In this example, deferring the `isPending` value with [`useDeferredValue`](https://react.dev/reference/react/useDeferredValue) helps avoid the loading indicator from flashing briefly when the navigation finishes quickly or the destination doesn't suspend. |
| 147 | + |
| 148 | +You can then use the `LinkButton` for navigation: |
| 149 | + |
| 150 | +```js |
| 151 | +<LinkButton screen="Profile" params={{ userId: 'jane' }}> |
| 152 | + Open profile |
| 153 | +</LinkButton> |
| 154 | +``` |
| 155 | + |
| 156 | +Similarly, you can wrap any navigation action in a transition and show a pending UI while the navigation transition is in progress. |
| 157 | + |
| 158 | +If you have multiple controls that can start a navigation, we recommend using separate transitions for each control, so that the loading state of one doesn't affect the others. |
| 159 | + |
| 160 | +If you use the [`Button`](elements.md#button) component from `@react-navigation/elements` for navigation, it automatically handles transitions and shows a loading indicator while the navigation is pending. |
| 161 | + |
| 162 | +## Handling errors |
| 163 | + |
| 164 | +Suspense boundaries only handle pending content. If loading fails, or the component throws during rendering, an [error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) can be used to show an error message instead of crashing the whole app: |
| 165 | + |
| 166 | +```js static2dynamic |
| 167 | +const RootStack = createNativeStackNavigator({ |
| 168 | + screenLayout: ({ children }) => ( |
| 169 | + // highlight-next-line |
| 170 | + <ErrorBoundary fallback={<ErrorFallback />}> |
| 171 | + <React.Suspense fallback={<LoadingPlaceholder />}> |
| 172 | + {children} |
| 173 | + </React.Suspense> |
| 174 | + </ErrorBoundary> |
| 175 | + ), |
| 176 | + // highlight-end |
| 177 | + screens: { |
| 178 | + Home: HomeScreen, |
| 179 | + Profile: ProfileScreen, |
| 180 | + }, |
| 181 | +}); |
| 182 | +``` |
| 183 | + |
| 184 | +Typically, the error boundary should give the user a way to recover, such as retrying the failed action or navigating back to a safe screen. |
0 commit comments