suspense-service
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>
);
Why suspense-service?
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);
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.
Installing
Package available on npm or Yarn
npm i suspense-service
yarn add suspense-service
Usage
Service
Basic Example
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/people/1/">
{/* Render fallback while MyComponent is suspended */}
<Suspense fallback="Loading data...">
<MyComponent />
</Suspense>
</MyService.Provider>
);
Render Callback
const MyComponent = () => (
<MyService.Consumer>
{(data) => <pre>{JSON.stringify(data, null, 2)}</pre>}
</MyService.Consumer>
);
Inline Suspense
const App = () => (
<MyService.Provider
request="https://swapi.dev/api/people/1/"
fallback="Loading data..."
>
<MyComponent />
</MyService.Provider>
);
Multiple Providers
const MyComponent = () => {
const a = useService(MyService, 'a');
const b = useService(MyService, 'b');
return <pre>{JSON.stringify({ a, b }, null, 2)}</pre>;
};
const App = () => (
<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 = () => (
<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 = () => {
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 = () => {
const [response, setRequest] = useServiceState(MyService);
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>
</>
);
};
Documentation
API Reference available on GitHub Pages
Code Coverage
Available on Codecov