Skip to content

Commit 5cf689e

Browse files
authored
feat: Support for Suspense 🎉 (#17)
* feat: added support for Suspense. Will throw Promise when loading === true * feat: wrap example with Suspense * feat: added support for suspense via `resource` returned from useData * feat(examples): updated example for Suspense * feat: added Suspense support for HOC as well * feat: added example for Suspense using HOC * chore: cleanup logs * chore: cleanup logs in example * feat: throw errors when error happened in resource.read() * docs: updated docs to talk about Suspense * docs: updated readme
1 parent b950279 commit 5cf689e

File tree

21 files changed

+306
-37
lines changed

21 files changed

+306
-37
lines changed

docusaurus/docs/hocs/withData.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ The HOC will inject an object as a props named `name` (depending on the `name` y
6565

6666
A function that will trigger refetching data from network. Fetching data from network this way will always bypass the cache, no matter what the [`fetchPolicy`](../others/caching.md#caching-strategies) is set to.
6767

68+
5. `resource: DataResource`
69+
70+
An object to be used when working with `Suspense`. Read more [here](../others/working-with-suspense.md)
71+
6872
Which are basically exactly the same as what [`useData()`](../hooks/useData.md) is returning.
6973

7074
### Supported methods

docusaurus/docs/hocs/withLazyData.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ The HOC will inject a 2-element array as a props named `name` (depending on the
7070

7171
A function that will trigger refetching data from network. Fetching data from network this way will always bypass the cache, no matter what the [`fetchPolicy`](../others/caching.md#caching-strategies) is set to.
7272

73+
6. `resource: DataResource`
74+
75+
An object to be used when working with `Suspense`. Read more [here](../others/working-with-suspense.md)
76+
7377
Which are basically exactly the same as what [`useLazyData()`](../hooks/useLazyData.md) is returning.
7478

7579
### Supported methods

docusaurus/docs/hooks/useData.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,9 @@ The returned value of `useData()` are:
5656

5757
A function that will trigger refetching data from network. Fetching data from network this way will always bypass the cache, no matter what the [`fetchPolicy`](../others/caching.md#caching-strategies) is set to.
5858

59+
5. `resource: DataResource`
60+
61+
An object to be used when working with `Suspense`. Read more [here](../others/working-with-suspense.md)
62+
5963
### Supported methods
6064
Only `GET` requests are supported for `useData()`. Supporting other methods is not in the plan because it can be dangerous to re-request a non-idempotent request on component state update.

docusaurus/docs/hooks/useLazyData.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,9 @@ The returned value of `useData()` are:
5858

5959
A function that will trigger refetching data from network. Fetching data from network this way will always bypass the cache, no matter what the [`fetchPolicy`](../others/caching.md#caching-strategies) is set to.
6060

61+
6. `resource: DataResource`
62+
63+
An object to be used when working with `Suspense`. Read more [here](../others/working-with-suspense.md)
64+
6165
### Supported methods
6266
All HTTP methods are supported. The example show usage of `POST` method, but it could be any HTTP method. But, only `GET` requests are cached.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
id: suspense
3+
title: Working with React Suspense (Experimental)
4+
sidebar_label: Working with React Suspense
5+
---
6+
7+
`react-isomoprhic-data` provides an easy way for you to implement the [render-as-you-fetch pattern with React Suspense](https://reactjs.org/docs/concurrent-mode-suspense.html#approach-3-render-as-you-fetch-using-suspense) throughout your app with the `resource` object returned by the hooks or HOCs you use.
8+
9+
This allow data to be loaded earlier without waiting a component (which might also be lazily loaded) to render first.
10+
11+
## How to implement it
12+
Let's say we are codesplitting part of our app like such:
13+
14+
```javascript
15+
import * as React from 'react';
16+
17+
const LazyLoadedView = React.lazy(() => import(/* webpackChunkName: "lazy-loaded-route" */ './views/main'));
18+
19+
const SuspenseRoute = () => {
20+
return (
21+
<ErrorBoundary errorView={<div>something wrong happened!</div>}>
22+
<React.Suspense fallback={<div>Route is not ready yet...</div>}>
23+
<LazyLoadedView />
24+
</React.Suspense>
25+
</ErrorBoundary>
26+
);
27+
};
28+
29+
export default SuspenseRoute;
30+
```
31+
We have `ErrorBoundary` to handle error cases inside our app. An explanation about `ErrorBoundary` can be found in the [React docs here](https://reactjs.org/docs/error-boundaries.html). We also have a `React.Suspense` wrapper that is required if we are using `React.lazy`.
32+
33+
Rendering this component will trigger browser to download the javascript chunk for `LazyLoadedView` component. If we were to use `useData` hook inside `LazyLoadedView`, the request for the data will only be sent *AFTER* the javascript chunk is loaded. This is what usually called a **waterfall**. It causes the overall loading time to be slower.
34+
35+
With `react-isomorphic-data` you can call `useData` inside the `SuspenseRoute` immediately.
36+
37+
```javascript
38+
import * as React from 'react';
39+
import { useData } from 'react-isomorphic-data';
40+
41+
const LazyLoadedView = React.lazy(() => import(/* webpackChunkName: "lazy-loaded-route" */ './views/main'));
42+
43+
const SuspenseRoute = () => {
44+
// We will load the data as we load the javascript chunk to avoid waterfalls
45+
const { data, loading, error } = useData('http://localhost:3000/some-rest-api/this-is-loaded-in-first-before-the-javascript-chunk');
46+
47+
return (
48+
<ErrorBoundary errorView={<div>something wrong happened!</div>}>
49+
<React.Suspense fallback={<div>Route is not ready yet...</div>}>
50+
{loading ? 'loading...' : null}
51+
{error ? 'something wrong happened' : null}
52+
{!loading && !error ? <LazyLoadedView data={data} /> : null}
53+
</React.Suspense>
54+
</ErrorBoundary>
55+
);
56+
};
57+
58+
export default SuspenseRoute;
59+
```
60+
61+
This doesn't solve the problem, though; it only moved the problem around. In this case, we are waiting for the data to be received first, and then we start to render `LazyLoadedView`. We will still have a waterfall, because rendering the `LazyLoadedView` waits for the data to be ready first.
62+
63+
Also, we need to handle the `loading` and `error` states by ourselves. This means we will have 2 pairs loading and error states, one for the data, and another one for the loading the javascript chunk, Wouldn't it be nice if we can just let the existing `Suspense` and `ErrorBoundary` to handle both of them? That is exactly what `react-isomorphic-data` hope to achieve by returning `resource`.
64+
65+
```javascript
66+
import * as React from 'react';
67+
import { useData } from 'react-isomorphic-data';
68+
69+
const LazyLoadedView = React.lazy(() => import(/* webpackChunkName: "lazy-loaded-route" */ './views/main'));
70+
71+
const SuspenseRoute = () => {
72+
// We will load the data as we load the javascript chunk to avoid waterfalls
73+
const { resource } = useData('http://localhost:3000/some-rest-api/this-is-loaded-in-parallel-with-the-route-chunk');
74+
75+
return (
76+
<ErrorBoundary errorView={<div>something wrong happened!</div>}>
77+
<React.Suspense fallback={<div>Route is not ready yet...</div>}>
78+
<LazyLoadedView resource={resource} />
79+
</React.Suspense>
80+
</ErrorBoundary>
81+
);
82+
};
83+
84+
export default SuspenseRoute;
85+
```
86+
87+
The `useData` hook return a `resource` and our wrapper just pass the `resource` to the component that will need the resource; which in this case, is the `LazyLoadedView`. Notice that we just removed all the code needed for handling error and loading states, and just depend on `ErrorBoundary` and `Suspense` to handle those states.
88+
89+
We also start sending requests for both the data and the javascript chunks in parallel in this case, which means we have successfully avoided the waterfall here.
90+
91+
How do we actually use the data in `LazyLoadedView` though? It is very simple. We just call `resource.read()` which will return the data that would be returned from the REST API.
92+
93+
```javascript
94+
import * as React from 'react';
95+
96+
// This is the component that we lazy-loaded using `React.lazy`
97+
const SuspenseMainView = ({ resource }) => {
98+
// We get the data here by calling `resource.read()`
99+
// If it's not ready, this component will suspend automatically
100+
// If there is an error, it will throw an error as well, so the nearest ErrorBoundary can catch it
101+
const data = resource.read();
102+
103+
return (
104+
<div>
105+
<pre>{JSON.stringify(data, null, 2)}</pre>
106+
</div>
107+
);
108+
};
109+
110+
export default SuspenseMainView;
111+
```
112+
113+
And that's it! Using this pattern will help you achieve better performance for your web app, especially when your app enables [concurrent mode](https://reactjs.org/docs/concurrent-mode-intro.html), which will be available in the future.
114+
115+
>Warning ⚠️
116+
>
117+
>Please note that `Suspense` does not work with server-side rendering yet, so we can not let Suspense handle all our loading states just yet. Though, you still can implement the render-as-you-fetch pattern without Suspense, it will require more code to handle the loading states yourself.

docusaurus/sidebars.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ module.exports = {
1111
Hooks: ['hooks/usedata', 'hooks/uselazydata'],
1212
'Higher Order Components': ['hocs/withdata', 'hocs/withlazydata'],
1313
'Server-side Rendering': ['ssr/intro', 'ssr/rendertostringwithdata', 'ssr/getdatafromtree', 'ssr/client-side-hydration', 'ssr/prefetching'],
14-
'Others': ['others/caching', 'others/data-options', 'others/cant-find-answer'],
14+
'Others': ['others/caching', 'others/data-options', 'others/suspense', 'others/cant-find-answer'],
1515
},
1616
// 'react-isomorphic-data': {
1717
// Introduction: ['api/index', 'api/globals'],

examples/ssr/src/App.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import React from 'react';
22
import { Route, Switch, Link } from 'react-router-dom';
33
import Home from './Home';
4+
import SuspenseRoute from './routes/SuspenseExample';
45

56
import './App.css';
67

78
const App = () => {
89
return (
910
<>
11+
<nav className="navbar">
12+
<Link to="/">Home</Link>
13+
<Link to="/suspense-example">Suspense Example</Link>
14+
</nav>
1015
<Switch>
1116
<Route
12-
path="/somewhere"
13-
render={() => {
14-
return <Link to="/">Go back</Link>;
15-
}}
17+
path="/suspense-example"
18+
component={SuspenseRoute}
1619
/>
1720
<Route exact={true} path="/" component={Home} />
1821
</Switch>

examples/ssr/src/ComponentUsingHOC.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ export default withData({
3131
fetchOptions: {}, // options that can be accepted by the native `fetch` API
3232
dataOptions: { // additional options
3333
ssr: false,
34-
// fetchPolicy: 'cache-and-network',
34+
fetchPolicy: 'network-only',
3535
},
3636
})(ComponentUsingHOC);

examples/ssr/src/Home.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,13 @@ pre {
5555
padding: 8px 16px;
5656
background: #005899;
5757
color: white;
58+
}
59+
60+
.navbar > a{
61+
display: inline-flex;
62+
border: 1px solid gray;
63+
padding: 8px;
64+
width: 150px;
65+
justify-content: center;
66+
align-items: center;
5867
}

examples/ssr/src/Home.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react';
22
import { useData, useLazyData } from 'react-isomorphic-data';
3-
import { Link } from 'react-router-dom';
43

54
import ComponentUsingHOC from './ComponentUsingHOC';
65
import ComponentUsingLazyHOC from './ComponentUsingLazyHOC';
@@ -65,7 +64,6 @@ const Home = () => {
6564

6665
return (
6766
<div className="Home">
68-
<Link to="/somewhere">Go /somewhere</Link>
6967
<div className="Home-header">
7068
<img src={logo} className="Home-logo" alt="logo" />
7169
<h2>

examples/ssr/src/client.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { BrowserRouter } from 'react-router-dom';
66
import App from './App';
77

88
declare global {
9-
interface Window { __cache: any; }
9+
interface Window {
10+
__cache: any;
11+
}
1012
}
1113

1214
const dataClient = createDataClient({
@@ -20,9 +22,9 @@ hydrate(
2022
<App />
2123
</BrowserRouter>
2224
</DataProvider>,
23-
document.getElementById('root')
25+
document.getElementById('root'),
2426
);
2527

2628
if (module.hot) {
2729
module.hot.accept();
28-
}
30+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as React from 'react';
2+
import { DataResource } from 'react-isomorphic-data/dist/types/hooks/types';
3+
4+
const SomeData: React.SFC<{ resource: DataResource }> = ({ resource }) => {
5+
const data = resource.read();
6+
7+
return <div>
8+
<pre>{JSON.stringify(data, null, 2)}</pre>
9+
</div>
10+
}
11+
12+
export default SomeData;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as React from 'react';
2+
import { withData } from 'react-isomorphic-data';
3+
import { DataState } from 'react-isomorphic-data/dist/types/hooks/types';
4+
import SomeData from './SomeData';
5+
6+
const SuspendedWithHOC: React.SFC<{ someData: DataState }> = ({ someData }) => {
7+
const { resource } = someData;
8+
9+
return (
10+
<React.Suspense fallback={<div><code>SuspendedWithHOC</code> is not ready yet...</div>}>
11+
<SomeData resource={resource} />
12+
</React.Suspense>
13+
)
14+
}
15+
16+
export default withData({
17+
url: 'http://localhost:3000/some-rest-api/suspense-with-hoc',
18+
name: 'someData', // the name of the prop the data will be injected to
19+
queryParams: {},
20+
fetchOptions: {}, // options that can be accepted by the native `fetch` API
21+
dataOptions: { // additional options
22+
ssr: false,
23+
fetchPolicy: 'cache-and-network',
24+
},
25+
})(SuspendedWithHOC);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as React from 'react';
2+
import { useData } from 'react-isomorphic-data';
3+
4+
const LazyLoadedView = React.lazy(() => import(/* webpackChunkName: "lazy-loaded-route" */ './views/main'));
5+
6+
const SuspenseRoute: React.SFC<{}> = () => {
7+
// We will load the data as we load the javascript chunk to avoid waterfalls
8+
const { loading, resource } = useData(
9+
'http://localhost:3000/some-rest-api/this-is-loaded-in-parallel-with-the-route-chunk',
10+
{},
11+
{},
12+
{
13+
ssr: false,
14+
fetchPolicy: 'network-only',
15+
},
16+
);
17+
18+
return (
19+
<React.Suspense fallback={<div>Route is not ready yet...</div>}>
20+
<LazyLoadedView resource={resource} />
21+
</React.Suspense>
22+
);
23+
};
24+
25+
export default SuspenseRoute;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as React from 'react';
2+
import { DataResource } from 'react-isomorphic-data/dist/types/hooks/types';
3+
import SuspendedWithHOC from '../components/SuspendedWithHOC';
4+
5+
const SuspenseMainView: React.SFC<{ resource: DataResource }> = ({ resource }) => {
6+
// We get the data here by calling `resource.read()`
7+
// If it's not ready, this component will suspend automatically
8+
const data = resource.read();
9+
10+
return (
11+
<>
12+
<div>
13+
<pre>{JSON.stringify(data, null, 2)}</pre>
14+
</div>
15+
<SuspendedWithHOC />
16+
</>
17+
);
18+
};
19+
20+
export default SuspenseMainView;

packages/react-isomorphic-data/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ NOTE: This project is still very much work in progress, use at your own risk ⚠
99
- React hooks
1010
- SSR support
1111
- Simple built-in cache
12+
- Support for React Suspense (experimental) ⚠️
1213

1314
### Installing
1415
```

packages/react-isomorphic-data/src/hoc/withData.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,9 @@ const withData = (options: HocOptions) => {
1313

1414
return (Component: React.ElementType) => {
1515
return hoistNonReactStatics((props: any) => {
16-
const { data, loading, error } = useData(url, queryParams, fetchOptions, dataOptions);
16+
const baseData = useData(url, queryParams, fetchOptions, dataOptions);
1717
const dataProps = {
18-
[name || 'data']: {
19-
data,
20-
loading,
21-
error,
22-
},
18+
[name || 'data']: baseData,
2319
};
2420

2521
return <Component {...props} {...dataProps} />;

packages/react-isomorphic-data/src/hoc/withLazyData.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,11 @@ const withData = (options: HocOptions) => {
1313

1414
return (Component: React.ElementType) => {
1515
return hoistNonReactStatics((props: any) => {
16-
const [load, { data, loading, error }] = useLazyData(url, queryParams, fetchOptions, dataOptions);
16+
const [load, baseData] = useLazyData(url, queryParams, fetchOptions, dataOptions);
1717
const dataProps = {
1818
[name || 'data']: [
1919
load,
20-
{
21-
data,
22-
loading,
23-
error,
24-
},
20+
baseData
2521
],
2622
};
2723

packages/react-isomorphic-data/src/hooks/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
export interface DataResource {
2+
read: () => any;
3+
};
4+
15
export interface DataState {
26
data: any;
37
error: Error | boolean | null;
48
loading: boolean;
59
refetch: () => Promise<any>;
10+
resource: DataResource;
611
};
712

813
export interface DataHookState {

0 commit comments

Comments
 (0)