Why React Suspense Will Be a Game Changer
This is the demo app I used for my talk at the ReactJS Brisbane meetup about React Suspense. It's also complimentary to the medium article I wrote a while back.
I used Created React App for this demo as well as the latest canary version of React. Please note that Suspense for data loading is still highly experimental and APIs will change!
How to use this repo
You'll see several branches in this repo, walking you through the "evolution" of data handling in React apps, from local state and Redux to Suspense, showing how it simplifies the way we deal with async data and loading states in our applications in the future.
To get started, simply clone the repo, check out the branch you want, install dependencies and ... well, that's it
# Clone repo
git clone git@github.com:julianburr/talk-suspense.git
cd talk-suspense
# Optionally switch branch
git checkout ...
# Install dependencies
yarn
# Start dev server
yarn start
I also created my slides in this repo:
# Clone + install dependencies as above
# Start slideshow
yarn start:slides
Summary
Local State
The simplest way of dealing with async data flows is by using local state. All you need to store a loading (and maybe an error) state, as well as the actual data. With classes this could look something like this.
import React, { Component } from 'React'
class Example extends Component {
state = {
loading: true,
error: null,
data: null
}
// Fetch data when component mounts
componentDidMount () {
getData(this.props.id)
.then(data => {
this.setState({
loading: false,
error: null,
data
})
})
.catch(e => {
this.setState({
loading: false,
error: e.message,
data: null
})
});
}
// We need to refetch the data when the id changes,
// so we listen for that in `cDU`
componentDidUpdate (prevProps) {
if (this.props.id !== prevProps.id) {
this.setState({ loading: true });
getData(this.props.id)
.then(data => {
this.setState({
loading: false,
error: null,
data
})
})
.catch(e => {
this.setState({
loading: false,
error: e.message,
data: null
})
});
}
}
render () {
if (this.state.loading) {
return <p>Loading...</p>;
}
if (this.state.error) {
return <p>Error: {this.state.error}</p>;
}
return <p>Loaded! {this.state.data}</p>;
}
}
This is a hell of a lot of boilerplate for such a simple scenario. Hooks make it already a bit better...
import React, { useState, useEffect } from 'react'
function Example ({ id }) {
const [ loading, setLoading ] = useState(true);
const [ error, setError ] = useState(null);
const [ data, setData ] = useState(null);
useEffect(() => {
setLoading(true);
getData(id)
.then(data => {
setLoading(false);
setError(null);
setData(data);
})
.catch(e => {
setLoading(false);
setError(e.message);
setData(null);
})
}, [ id ]);
if (loading) {
return <p>Loading...</p>;
}
if (error) {
return <p>Error: {error}</p>;
}
return <p>Loaded! {data}</p>;
}
...but there are still several problems with this:
π Ugly ternaries β bad DXπ Boilerplate β bad DXπ Confined data and loading state β bad DX & UXπ Re-fetching data β bad DXπ Flashing spinners β bad UX
Global state management, i.e. Context
Some of these can be addressed by hoisting up the data handling to a global state management layer. This could e.g. something like Redux, MobX or just Context, which I'll go with here for the sake of simplicity.
const DataContext = React.createContext();
function DataProvider (props) {
const [ cache, setCache ] = useState({});
async function fetch (id) {
if (cache[id]) {
return cache[id];
}
setCache({
...cache,
[id]: { loading: true }
});
try {
const data = await getData(id);
setCache({
...cache,
[id]: {
loading: false,
error: null,
data
}
});
} catch (e) {
setCache({
...cache,
[id]: {
loading: false,
error: e.message,
data: null
}
});
}
}
return <DataContext.Provider value={{data: cache, fetch}} {...props} />;
}
function Example ({ id }) {
const { fetch, data } = useContext(DataContext);
useEffect(() => {
fetch(id)
}, [ id ]);
if (!data[id] || data[id].loading) {
return <p>Loading...</p>;
}
if (data[id].error) {
return <p>Error: {data[id].error}</p>;
}
return <p>Loaded! {data[id].data}</p>;
}
Still a lot of boilerplate, but at least the final component consuming the data is a lot simpler, and we solved a bunch of our initial issues, yay us
π Ugly ternariesπ Boilerplateπ Confined data and loading stateπ Re-fetching dataπ Flashing spinners
Still not great though...
Suspense to the rescue
import React, { Suspense } from 'react';
import cache from './awesome-cache-provider'; // π
function Example ({ id }) {
const data = cache.read(id);
return <p>Loaded! {data}</p>;
}
function App () {
return (
<Suspense fallback={<p>Loading...</p>}>
<Example id={1} />
<Example id={2} />
</Suspense>
);
}
Nice, this already looks a lot cleaner.
β€οΈ Ugly ternariesβ€οΈ Boilerplateβ€οΈ Confined data and loading stateβ€οΈ Re-fetching dataπ Flashing spinners
Concurrent mode as the final touch
- ReactDOM.render(<App />, document.getElementById('root'));
+ ReactDOM.unstable_createRoot(document.getElementById(βrootβ)).render(<App />);
That's it. Now we can give <Suspense />
a maxDuration prop that allows us to prevent anything from rendering for this amount of time, making it possible to not show any spinners on fast networks and thereby hugely improving the user experience.