React Suspense in Practice
Publikováno: 19.3.2020
This post is about understanding how Suspense works, what it does, and seeing how it can integrate into a real web app. We'll look at how to integrate routing and data loading with Suspense in React. For routing, I'll be using vanilla JavaScript, and I'll be using my own micro-graphql-react GraphQL library for data.
If you're wondering about React Router, it seems great, but I've never had the chance to use it. My own side project has a simple enough … Read article “React Suspense in Practice”
The post React Suspense in Practice appeared first on CSS-Tricks.
This post is about understanding how Suspense works, what it does, and seeing how it can integrate into a real web app. We'll look at how to integrate routing and data loading with Suspense in React. For routing, I'll be using vanilla JavaScript, and I'll be using my own micro-graphql-react GraphQL library for data.
If you're wondering about React Router, it seems great, but I've never had the chance to use it. My own side project has a simple enough routing story that I always just did it by hand. Besides, using vanilla JavaScript will give us a better look at how Suspense works.
A little background
Let’s talk about Suspense itself. Kingsley Silas provides a thorough overview of it, but the first thing to note is that it's still an experimental API. That means — and React’s docs say the same — not to lean on it yet for production-ready work. There’s always a chance it will change between now and when it’s fully complete, so please bear that in mind.
That said, Suspense is all about maintaining a consistent UI in the face of asynchronous dependencies, such as lazily loaded React components, GraphQL data, etc. Suspense provides low-level API's that allow you to easily maintain your UI while your app is managing these things.
But what does "consistent" mean in this case? It means not rendering a UI that's partially complete. It means, if there are three data sources on the page, and one of them has completed, we don't want to render that updated piece of state, with a spinner next to the now-outdated other two pieces of state.
What we do want to do is indicate to the user that data are loading, while continuing to show either the old UI, or an alternative UI which indicates we're waiting on data; Suspense supports either, which I'll get into.
What exactly Suspense does
This is all less complicated than it may seem. Traditionally in React, you'd set state, and your UI would update. Life was simple. But it also led to the sorts of inconsistencies described above. What Suspense adds is the ability to have a component notify React at render time that it's waiting for asynchronous data; this is called suspending, and it can happen anywhere in a component's tree, as many times as needed, until the tree is ready. When a component suspends, React will decline to render the pending state update until all suspended dependencies have been satisfied.
So what happens when a component suspends? React will look up the tree, find the first <Suspense>
component, and render its fallback. I'll be providing plenty of examples, but for now, know that you can provide this:
<Suspense fallback={<Loading />}>
…and the <Loading />
component will render if any child components of <Suspense>
are suspended.
But what if we already have a valid, consistent UI, and the user loads new data, causing a component to suspend? This would cause the entire existing UI to un-render, and the fallback to show. That'd still be consistent, but hardly a good UX. We'd prefer the old UI stay on the screen while the new data are loading.
To support this, React provides a second API, useTransition, which effectively makes a state change in memory. In other words, it allows you to set state in memory while keeping your existing UI on screen; React will literally keep a second copy of your component tree rendered in memory, and set state on that tree. Components may suspend, but only in memory, so your existing UI will continue to show on the screen. When the state change is complete, and all suspensions have resolved, the in-memory state change will render onto the screen. Obviously you want to provide feedback to your user while this is happening, so useTransition
provides a pending
boolean, which you can use to display some sort of inline "loading" notification while suspensions are being resolved in memory.
When you think about it, you probably don't want your existing UI to show indefinitely while your loading is pending. If the user tries to do something, and a long period of time elapses before it's finished, you should probably consider the existing UI outdated and invalid. At this point, you probably will want your component tree to suspend, and your <Suspense>
fallback to display.
To accomplish this, useTransition
takes a timeoutMs
value. This indicates the amount of time you're willing to let the in-memory state change run, before you suspend.
const Component = props => {
const [startTransition, isPending] = useTransition({ timeoutMs: 3000 });
// .....
};
Here, startTransition
is a function. When you want to run a state change "in memory," you call startTransition
, and pass a lambda expression that does your state change.
startTransition(() => {
dispatch({ type: LOAD_DATA_OR_SOMETHING, value: 42 });
})
You can call startTransition
wherever you want. You can pass it to child components, etc. When you call it, any state change you perform will happen in memory. If a suspension happens, isPending
will become true, which you can use to display some sort of inline loading indicator.
That's it. That's what Suspense does.
The rest of this post will get into some actual code to leverage these features.
Example: Navigation
To tie navigation into Suspense, you'll be happy to know that React provides a primitive to do this: React.lazy
. It's a function that takes a lambda expression that returns a Promise, which resolves to a React component. The result of this function call becomes your lazily loaded component. It sounds complicated, but it looks like this:
const SettingsComponent = lazy(() => import("./modules/settings/settings"));
SettingsComponent
is now a React component that, when rendered (but not before), will call the function we passed in, which will call import()
and load the JavaScript module located at ./modules/settings/settings
.
The key piece is this: while that import()
is in flight, the component rendering SettingsComponent
will suspend. It seems we have all the pieces in hand, so let's put them together and build some Suspense-based navigation.
Navigation helpers
But first, for context, I'll briefly cover how navigation state is managed in this app, so the Suspense code will make more sense.
I'll be using my booklist app. It's just a side project of mine I mainly keep around to mess around with bleeding-edge web technology. It was written by me alone, so expect parts of it to be a bit unrefined (especially the design).
The app is small, with about eight different modules a user can browse to, without any deeper navigation. Any search state a module might use is stored in the URL’s query string. With this in mind, there are a few methods which scrape the current module name, and search state from the URL. This code uses the query-string
and history
packages from npm, and looks somewhat like this (some details have been removed for simplicity, like authentication).
import createHistory from "history/createBrowserHistory";
import queryString from "query-string";
export const history = createHistory();
export function getCurrentUrlState() {
let location = history.location;
let parsed = queryString.parse(location.search);
return {
pathname: location.pathname,
searchState: parsed
};
}
export function getCurrentModuleFromUrl() {
let location = history.location;
return location.pathname.replace(/\//g, "").toLowerCase();
}
I have an appSettings
reducer that holds the current module and searchState
values for the app, and uses these methods to sync with the URL when needed.
The pieces of a Suspense-based navigation
Let's get started with some Suspense work. First, let's create the lazy-loaded components for our modules.
const ActivateComponent = lazy(() => import("./modules/activate/activate"));
const AuthenticateComponent = lazy(() =>
import("./modules/authenticate/authenticate")
);
const BooksComponent = lazy(() => import("./modules/books/books"));
const HomeComponent = lazy(() => import("./modules/home/home"));
const ScanComponent = lazy(() => import("./modules/scan/scan"));
const SubjectsComponent = lazy(() => import("./modules/subjects/subjects"));
const SettingsComponent = lazy(() => import("./modules/settings/settings"));
const AdminComponent = lazy(() => import("./modules/admin/admin"));
Now we need a method that chooses the right component based on the current module. If we were using React Router, we'd have some nice <Route />
components. Since we're rolling this manually, a switch
will do.
export const getModuleComponent = moduleToLoad => {
if (moduleToLoad == null) {
return null;
}
switch (moduleToLoad.toLowerCase()) {
case "activate":
return ActivateComponent;
case "authenticate":
return AuthenticateComponent;
case "books":
return BooksComponent;
case "home":
return HomeComponent;
case "scan":
return ScanComponent;
case "subjects":
return SubjectsComponent;
case "settings":
return SettingsComponent;
case "admin":
return AdminComponent;
}
return HomeComponent;
};
The whole thing put together
With all the boring setup out of the way, let's see what the entire app root looks like. There's a lot of code here, but I promise, relatively few of these lines pertain to Suspense, and I'll cover all of it.
const App = () => {
const [startTransitionNewModule, isNewModulePending] = useTransition({
timeoutMs: 3000
});
const [startTransitionModuleUpdate, moduleUpdatePending] = useTransition({
timeoutMs: 3000
});
let appStatePacket = useAppState();
let [appState, _, dispatch] = appStatePacket;
let Component = getModuleComponent(appState.module);
useEffect(() => {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
}, []);
useEffect(() => {
return history.listen(location => {
if (appState.module != getCurrentModuleFromUrl()) {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
} else {
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
}
});
}, [appState.module]);
return (
<AppContext.Provider value={appStatePacket}>
<ModuleUpdateContext.Provider value={moduleUpdatePending}>
<div>
<MainNavigationBar />
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null}
</div>
</Suspense>
</div>
</ModuleUpdateContext.Provider>
</AppContext.Provider>
);
};
First, we have two different calls to useTransition
. We'll use one for routing to a new module, and the other for updating search state for the current module. Why the difference? Well, when a module's search state is updating, that module will likely want to display an inline loading indicator. That updating state is held by the moduleUpdatePending
variable, which you'll see I put on context for the active module to grab, and use as needed:
<div>
<MainNavigationBar />
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null} // highlight
</div>
</Suspense>
</div>
The appStatePacket
is the result of the app state reducer I discussed above (but did not show). It contains various pieces of application state which rarely change (color theme, offline status, current module, etc).
let appStatePacket = useAppState();
A little later, I grab whichever component happens to be active, based on the current module name. Initially this will be null.
let Component = getModuleComponent(appState.module);
The first call to useEffect
will tell our appSettings
reducer to sync with the URL at startup.
useEffect(() => {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
}, []);
Since this is the initial module the web app navigates to, I wrap it in startTransitionNewModule
to indicate that a fresh module is loading. While it might be tempting to have the appSettings
reducer have the initial module name as its initial state, doing this prevents us from calling our startTransitionNewModule
callback, which means our Suspense boundary would render the fallback immediately, instead of after the timeout.
The next call to useEffect
sets up a history subscription. No matter what, when the url changes we tell our app settings to sync against the URL. The only difference is which startTransition
that same call is wrapped in.
useEffect(() => {
return history.listen(location => {
if (appState.module != getCurrentModuleFromUrl()) {
startTransitionNewModule(() => {
dispatch({ type: URL_SYNC });
});
} else {
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
}
});
}, [appState.module]);
If we're browsing to a new module, we call startTransitionNewModule
. If we're loading a component that hasn't been loaded already, React.lazy
will suspend, and the pending indicator visible only to the app's root will set, which will show a loading spinner at the top of the app while the lazy component is fetched and loaded. Because of how useTransition
works, the current screen will continue to show for three seconds. If that time expires and the component is still not ready, our UI will suspend, and the fallback will render, which will show the <LongLoading />
component:
{isNewModulePending ? <Loading /> : null}
<Suspense fallback={<LongLoading />}>
<div id="main-content" style={{ flex: 1, overflowY: "auto" }}>
{Component ? <Component updating={moduleUpdatePending} /> : null}
</div>
</Suspense>
If we're not changing modules, we call startTransitionModuleUpdate
:
startTransitionModuleUpdate(() => {
dispatch({ type: URL_SYNC });
});
If the update causes a suspension, the pending indicator we're putting on context will be triggered. The active component can detect that and show whatever inline loading indicator it wants. As before, if the suspension takes longer than three seconds, the same Suspense boundary from before will be triggered... unless, as we'll see later, there's a Suspense boundary lower in the tree.
One important thing to note is that these three-second timeouts apply not only to the component loading, but also being ready to display. If the component loads in two seconds, and, when rendering in memory (since we're inside of a startTransition
call) suspends, the useTransition
will continue to wait for up to one more second before Suspending.
In writing this blog post, I used Chrome's slow network modes to help force loading to be slow, to test my Suspense boundaries. The settings are in the Network tab of Chrome's dev tools.
Let's open our app to the settings module. This will be called:
dispatch({ type: URL_SYNC });
Our appSettings
reducer will sync with the URL, then set module to "settings." This will happen inside of startTransitionNewModule
so that, when the lazy-loaded component attempts to render, it'll suspend. Since we're inside startTransitionNewModule
, the isNewModulePending
will switch over to true
, and the <Loading />
component will render.
So what happens when we browse somewhere new? Basically the same thing as before, except this call:
dispatch({ type: URL_SYNC });
…will come from the second instance of useEffect
. Let's browse to the books module and see what happens. First, the inline spinner shows as expected:
Searching and updating
Let's stay within the books module, and update the URL search string to kick off a new search. Recall from before that we were detecting the same module in that second useEffect
call and using a dedicated useTransition
call for it. From there, we were putting the pending indicator on context for whichever module was active for us to grab and use.
Let's see some code to actually use that. There's not really much Suspense-related code here. I’m grabbing the value from context, and if true, rendering an inline spinner on top of my existing results. Recall that this happens when a useTransition
call has begun, and the app is suspended in memory. While that’s happening, we continue to show the existing UI, but with this loading indicator.
const BookResults: SFC<{ books: any; uiView: any }> = ({ books, uiView }) => {
const isUpdating = useContext(ModuleUpdateContext);
return (
<>
{!books.length ? (
<div
className="alert alert-warning"
style={{ marginTop: "20px", marginRight: "5px" }}
>
No books found
</div>
) : null}
{isUpdating ? <Loading /> : null}
{uiView.isGridView ? (
<GridView books={books} />
) : uiView.isBasicList ? (
<BasicListView books={books} />
) : uiView.isCoversList ? (
<CoversView books={books} />
) : null}
</>
);
};
Let's set a search term and see what happens. First, the inline spinner displays.
Then, if the useTransition
timeout expires, we'll get the Suspense boundary's fallback. The books module defines its own Suspense boundary in order to provide a more fine-tuned loading indicator, which looks like this:
This is a key point. When making Suspense boundary fallbacks, try not to throw up any sort of spinner and "loading" message. That made sense for our top-level navigation because there's not much else to do. But when you're in a specific part of your application, try to make your fallback re-use many of the same components with some sort of loading indicator where the data would be — but with everything else disabled.
This is what the relevant components look like for my books module:
const RenderModule: SFC<{}> = ({}) => {
const uiView = useBookSearchUiView();
const [lastBookResults, setLastBookResults] = useState({
totalPages: 0,
resultsCount: 0
});
return (
<div className="standard-module-container margin-bottom-lg">
<Suspense fallback={<Fallback uiView={uiView} {...lastBookResults} />}>
<MainContent uiView={uiView} setLastBookResults={setLastBookResults} />
</Suspense>
</div>
);
};
const Fallback: SFC<{
uiView: BookSearchUiView;
totalPages: number;
resultsCount: number;
}> = ({ uiView, totalPages, resultsCount }) => {
return (
<>
<BooksMenuBarDisabled
totalPages={totalPages}
resultsCount={resultsCount}
/>
{uiView.isGridView ? (
<GridViewShell />
) : (
<h1>
Books are loading <i className="fas fa-cog fa-spin"></i>
</h1>
)}
</>
);
};
A quick note on consistency
Before we move on, I'd like to point out one thing from the earlier screenshots. Look at the inline spinner that displays while the search is pending, then look at the screen when that search suspended, and next, the finished results:
Notice how there's a "C++" label to the right of the search pane, with an option to remove it from the search query? Or rather, notice how that label is only on the second two screenshots? The moment the URL updates, the application state governing that label is updated; however, that state does not initially display. Initially, the state update suspends in memory (since we used useTransition), and the prior UI continues to show.
Then the fallback renders. The fallback renders a disabled version of that same search bar, which does show the current search state (by choice). We've now removed our prior UI (since by now it’s quite old, and stale) and are waiting on the search shown in the disabled menu bar.
This is the sort of consistency Suspense gives you, for free.
You can spend your time crafting nice application states, and React does the leg work of surmising whether things are ready, without you needing to juggle promises.
Nested Suspense boundaries
Let's suppose our top-level navigation takes a while to load our books component to the extent that our “Still loading, sorry” spinner from the Suspense boundary renders. From there, the books component loads and the new Suspense boundary inside the books component renders. But, then, as rendering continues, our book search query fires, and suspends. What will happen? Will the top-level Suspense boundary continue to show, until everything is ready, or will the lower-down Suspense boundary in books take over?
The answer is the latter. As new Suspense boundaries render lower in the tree, their fallback will replace the fallback of whatever antecedent Suspense fallback was already showing. There's currently an unstable API to override this, but if you're doing a good job of crafting your fallbacks, this is probably the behavior you want. You don't want “Still loading, sorry” to just keep showing. Rather, as soon as the books component is ready, you absolutely want to display that shell with the more targeted waiting message.
Now, what if our books module loads and starts to render while the startTransition
spinner is still showing and then suspends? In other words, imagine that our startTransition
has a timeout of three seconds, the books component renders, the nested Suspense boundary is in the component tree after one second, and the search query suspends. Will the remaining two seconds elapse before that new nested Suspense boundary renders the fallback, or will the fallback show immediately? The answer, perhaps surprisingly, is that the new Suspense fallback will show immediately by default. That’s because it's best to show a new, valid UI as quickly as possible, so the user can see that things are happening, and progressing.
How data fits in
Navigation is fine, but how does data loading fit into all of this?
It fits in completely and transparently. Data loading triggers suspensions just like navigation with React.lazy
, and it hooks into all the same useTransition
and Suspense boundaries. This is what's so amazing about Suspense: all your async dependencies seamlessly work in this same system. Managing these various async requests manually to ensure consistency was a nightmare before Suspense, which is precisely why nobody did it. Web apps were notorious for cascading spinners that stopped at unpredictable times, producing inconsistent UIs that were only partially finished.
OK, but how do we actually tie data loading into this? Data loading in Suspense is paradoxically both more complex, and also simple.
I'll explain.
If you're waiting on data, you'll throw a promise in the component that reads (or attempts to read) the data. The promise should be consistent based on the data request. So, four repeated requests for that same "C++" search query should throw the same, identical promise. This implies some sort of caching layer to manage all this. You'll likely not write this yourself. Instead, you'll just hope, and wait for the data library you use to update itself to support Suspense.
This is already done in my micro-graphql-react library. Instead of using the useQuery
hook, you’ll use the useSuspenseQuery
hook, which has an identical API, but throws a consistent promise when you're waiting on data.
Wait, what about preloading?!
Has your brain turned to mush reading other things on Suspense that talked about waterfalls, fetch-on-render, preloading, etc? Don't worry about it. Here's what it all means.
Let's say you lazy load the books component, which renders and then requests some data, which causes a new Suspense. The network request for the component and the network request for the data will happen one after the other—in a waterfall fashion.
But here's the key part: the application state that led to whatever initial query that ran when the component loaded was already available when you started loading the component (which, in this case, is the URL). So why not "start" the query as soon as you know you'll need it? As soon as you browse to /books
, why not fire off the current search query right then and there, so it's already in flight when the component loads.
The micro-graphql-react module does indeed have a preload
method, and I urge you to use it. Preloading data is a nice performance optimization, but it has nothing to do with Suspense. Classic React apps could (and should) preload data as soon as they know they'll need it. Vue apps should preload data as soon as they know they'll need it. Svelte apps should... you get the point.
Preloading data is orthogonal to Suspense, which is something you can do with literally any framework. It’s also something we all should have been doing already, even though nobody else was.
But seriously, how do you preload?
That's up to you. At the very least, the logic to run the current search absolutely needs to be completely separated into its own, standalone module. You should literally make sure this preload function is in a file by itself. Don't rely on webpack to treeshake; you'll likely face abject sadness the next time you audit your bundles.
You have a preload()
method in its own bundle, so call it. Call it when you know you're about to navigate to that module. I assume React Router has some sort of API to run code on a navigation change. For the vanilla routing code above, I call the method in that routing switch from before. I had omitted it for brevity, but the books entry actually looks like this:
switch (moduleToLoad.toLowerCase()) {
case "activate":
return ActivateComponent;
case "authenticate":
return AuthenticateComponent;
case "books":
// preload!!!
booksPreload();
return BooksComponent;
That's it. Here's a live demo to play around with:
To modify the Suspense timeout value, which defaults to 3000ms, navigate to Settings, and check out the misc tab. Just be sure to refresh the page after modifying it.
Wrapping up
I've seldom been as excited for anything in the web dev ecosystem as I am for Suspense. It's an incredibly ambitious system for managing one of the trickiest problems in web development: asynchrony.
The post React Suspense in Practice appeared first on CSS-Tricks.