Suspense integration library for React
import { Suspense } from 'react';
import { createService, useService } from 'suspense-service';
const myHandler = async (request) => {
const response = await fetch(request);
return response.json();
};
const MyService = createService(myHandler);
const MyComponent = () => {
const data = useService(MyService);
return (
<pre>
{JSON.stringify(data, null, 2)}
</pre>
);
};
const App = () => (
<MyService.Provider request="https://swapi.dev/api/planets/2/">
<Suspense fallback="Loading data...">
<MyComponent />
</Suspense>
</MyService.Provider>
);
This library aims to provide a generic integration between promise-based data fetching and React's Suspense API, eliminating much of the boilerplate associated with state management of asynchronous data. Without Suspense, data fetching often looks like this:
import { useState, useEffect } from 'react';
const MyComponent = ({ request }) => {
const [data, setData] = useState();
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async (request) => {
const response = await fetch(request);
setData(await response.json());
setLoading(false);
};
fetchData(request);
}, [request]);
if (loading) {
return 'Loading data...';
}
return (
<pre>
{JSON.stringify(data, null, 2)}
</pre>
);
};
const App = () => (
<MyComponent request="https://swapi.dev/api/planets/2/" />
);
This may work well for trivial cases, but the amount of effort and code required tends to increase significantly for anything more advanced. Here are a few difficulities with this approach that suspense-service
is intended to simplify.
Avoiding race conditions caused by out-of-order responses
Accomplishing this with the approach above would require additional logic to index each of the requests and compose a promise chain to ensure responses from older requests don't overwrite the current state when one from a more recent request is already available.
Concurrent Mode was designed to inherently solve this type of race condition using Suspense.
Providing the response to one or more deeply nested components
This would typically be done by passing the response down through props, or by creating a Context to provide the response. Both of these solutions would require a lot of effort, especially if you want to avoid re-rendering the intermediate components that aren't even using the response.
suspense-service
already creates an optimized context provider that allows the response to be consumed from multiple nested components without making multiple requests.
Memoizing expensive computations based on the response
Expanding on the approach above, care would be needed in order to write a useMemo()
that follows the Rules of Hooks, and the expensive computation would need to be made conditional on the availability of data
since it wouldn't be populated until a later re-render.
With suspense-service
, you can simply pass data
from useService()
to useMemo()
, and perform the computation unconditionally, because the component is suspended until the response is made available synchronously:
const MyComponent = () => {
const data = useService(MyService);
// some expensive computation
const formatted = useMemo(() => JSON.stringify(data, null, 2), [data]);
return (
<pre>
{formatted}
</pre>
);
};
Other solved problems
Concurrent Mode introduces some UI patterns that were difficult to achieve with the existing approach. These patterns include Transitions and Deferring a value.
Package available on npm or Yarn
npm i suspense-service
yarn add suspense-service
Basic Example
import { Suspense } from 'react';
import { createService, useService } from 'suspense-service';
/**
* A user-defined service handler
* It may accept a parameter of any type
* but it must return a promise or thenable
*/
const myHandler = async (request) => {
const response = await fetch(request);
return response.json();
};
/**
* A Service is like a Context
* It contains a Provider and a Consumer
*/
const MyService = createService(myHandler);
const MyComponent = () => {
// Consumes MyService synchronously by suspending
// MyComponent until the response is available
const data = useService(MyService);
return <pre>{JSON.stringify(data, null, 2)}</pre>;
};
const App = () => (
// Fetch https://swapi.dev/api/people/1/
<MyService.Provider request="https://swapi.dev/api/people/1/">
{/* Render fallback while MyComponent is suspended */}
<Suspense fallback="Loading data...">
<MyComponent />
</Suspense>
</MyService.Provider>
);
Render Callback
const MyComponent = () => (
// Subscribe to MyService using a callback function
<MyService.Consumer>
{(data) => <pre>{JSON.stringify(data, null, 2)}</pre>}
</MyService.Consumer>
);
Inline Suspense
const App = () => (
// Passing the optional fallback prop
// wraps a Suspense around the children
<MyService.Provider
request="https://swapi.dev/api/people/1/"
fallback="Loading data..."
>
<MyComponent />
</MyService.Provider>
);
Multiple Providers
const MyComponent = () => {
// Specify which Provider to use
// by passing the optional id parameter
const a = useService(MyService, 'a');
const b = useService(MyService, 'b');
return <pre>{JSON.stringify({ a, b }, null, 2)}</pre>;
};
const App = () => (
// Identify each Provider with a key
// by using the optional id prop
<MyService.Provider request="people/1/" id="a">
<MyService.Provider request="people/2/" id="b">
<Suspense fallback="Loading data...">
<MyComponent />
</Suspense>
</MyService.Provider>
</MyService.Provider>
);
Multiple Consumers
const MyComponent = () => (
// Specify which Provider to use
// by passing the optional id parameter
<MyService.Consumer id="a">
{(a) => (
<MyService.Consumer id="b">
{(b) => <pre>{JSON.stringify({ a, b }, null, 2)}</pre>}
</MyService.Consumer>
)}
</MyService.Consumer>
);
Pagination
const MyComponent = () => {
// Allows MyComponent to update MyService.Provider request
const [response, setRequest] = useServiceState(MyService);
const { previous: prev, next, results } = response;
const setPage = (page) => setRequest(page.replace(/^http:/, 'https:'));
return (
<>
<button disabled={!prev} onClick={() => setPage(prev)}>
Previous
</button>
<button disabled={!next} onClick={() => setPage(next)}>
Next
</button>
<ul>
{results.map((result) => (
<li key={result.url}>
<a href={result.url} target="_blank" rel="noreferrer">
{result.name}
</a>
</li>
))}
</ul>
</>
);
};
Transitions
Note that Concurrent Mode is required in order to enable Transitions.
const MyComponent = () => {
// Allows MyComponent to update MyService.Provider request
const [response, setRequest] = useServiceState(MyService);
// Renders current response while next response is suspended
const [startTransition, isPending] = unstable_useTransition();
const { previous: prev, next, results } = response;
const setPage = (page) => {
startTransition(() => {
setRequest(page.replace(/^http:/, 'https:'));
});
};
return (
<>
<button disabled={!prev || isPending} onClick={() => setPage(prev)}>
Previous
</button>{' '}
<button disabled={!next || isPending} onClick={() => setPage(next)}>
Next
</button>
{isPending && 'Loading next page...'}
<ul>
{results.map((result) => (
<li key={result.url}>
<a href={result.url} target="_blank" rel="noreferrer">
{result.name}
</a>
</li>
))}
</ul>
</>
);
};
API Reference available on GitHub Pages
Available on Codecov