useFetch: React custom hook for Fetch API with Suspense and Concurrent Mode in Mind

February 03, 2019 0 Comments

useFetch: React custom hook for Fetch API with Suspense and Concurrent Mode in Mind

 

 

Until we have react-cache or for a different purpose.

While I don’t feel like coding React without hooks, react-cache still seems to be still far away. Surely, caching in data fetching important, nevertheless I would like to seek possibilities of implementations only with React Hooks. These might be obsoleted in the future, but I want something today, that is “useFetch”. This could be still useful without react-cache in case sophisticated caching is not necessary.

I’ve started developing “useFetch” as a tutorial to build a custom hook.

Since then, the support for AbortController is added, which is incredible if you are fetching one-time-only data like typeahead suggest.

Now, to make this “useFetch” hook more usable, this article describes about supporting Suspense and also Concurrent React a bit.

Suspense is currently described in the Code-Splitting section. However, its general purpose is to catch a promise in a component tree and render a fallback element. When the promise is resolved, it will render a normal tree. The good part of Suspense is that you can place anywhere up in the tree, and you could even place only one Suspense at the top.

One uncertain point is whether this functionality will be the same when Suspense for data fetching is released. Never mind. We should be able to follow the upcoming changes.

There is no documentation about Concurrent Mode yet. As far as I understand, what we need to care is that a) the render function can be called more than once, and b) the render phase and the commit phase is not synchronous. As for a), the render function should be pure or at least it shouldn’t change the world if it’s called twice. For b), we shouldn’t assume the call order of useEffect. I may still misunderstand something, though. I hope some guidelines for React hooks to be available in the future.

Let’s look at how “useFetch” is implemented. If you would like to see how it’s used, jump to the next section.

Firstly, there’s some utility functions, and two notable ones:

const forcedReducer = state => !state;
const useForceUpdate = () => useReducer(forcedReducer, false)[1];

This is a custom hook to force re-rendering.

const createPromiseResolver = () => {
let resolve;
const promise = new Promise((r) => { resolve = r; });
return { resolve, promise };
};

This is a naive way to create promise that can be resolved from outside.

Next is the main part of the hook:

export const useFetch = (input, opts = defaultOpts) => {
const forceUpdate = useForceUpdate();
const error = useRef(null);
const loading = useRef(false);
const data = useRef(null);
const promiseResolver = useMemo(createPromiseResolver, [input, opts]);

We define some variables with useRef, and promiseResolver with useMemo.

  // ...continued
useEffect(() => {
let finished = false;
const abortController = new AbortController();
(async () => {
if (!input) return;
// start fetching
error.current = null;
loading.current = true;
data.current = null;
forceUpdate();
try {
const {
readBody = defaultReadBody,
...init
} = opts;
const response = await fetch(input, {
...init,
signal: abortController.signal,
});
// check response
if (response.ok) {
const body = await readBody(response);
if (!finished) {
finished = true;
data.current = body;
loading.current = false;
}
} else if (!finished) {
finished = true;
error.current = createFetchError(response);
loading.current = false;
}
} catch (e) {
if (!finished) {
finished = true;
error.current = e;
loading.current = false;
}
}
// finish fetching
promiseResolver.resolve();
forceUpdate();
})();
const cleanup = () => {
if (!finished) {
finished = true;
abortController.abort();
}
error.current = null;
loading.current = false;
data.current = null;
};
return cleanup;
}, [input, opts]);

Hmm, too long. Some notes: a) forceUpdate() is called when variables are updated. b) At the end of async function, primiseResolver.resolve() is called. c) the cleanup is to abort running fetch and clear variables. d) the input array of useEffect is [input, opts].

  // ...continued
if (loading.current) throw promiseResolver.promise;
return {
error: error.current,
data: data.current,
};
};

Finally, it returns some of the variables, but before that if it’s still in the loading state, it throws a promise so that Suspense catches it and show a fallback loading component.

Let me show the minimal example.

import React, { Suspense } from 'react';
import ReactDOM from 'react-dom';

import { useFetch } from 'react-hooks-fetch';

const Err = ({ error }) => <span>Error:{error.message}</span>;

const DisplayRemoteData = () => {
const url = 'https://jsonplaceholder.typicode.com/posts/1&apos;;
const { error, data } = useFetch(url);
if (error) return <Err error={error} />;
if (!data) return null;
return <span>RemoteData:{data.title}</span>;
};

const App = () => (
<Suspense fallback={<span>Loading...</span>}>
<DisplayRemoteData />
</Suspense>
);

ReactDOM.render(<App />, document.getElementById('app'));

The component that uses useFetch does not handle the loading state and Suspense outside of the component does it and shows loading state. One important note is the line if (!data) return null; which is required because it’s null at first. This is not very nice at the moment, and we would like to find out if there’s a workaround.

You can try this example in codesandbox.

There are some other examples you can find in the GitHub repo with the link to codesandbox.

At first, I was hoping to use both Suspense and ErrorBoundary to show the loading state and the error state so that the main component with useFetch only cares about successful case. It turns out that it involves more or less hacks, and I ended up with normal error variable return by a hook. There’s also a note in the doc:

Only use error boundaries for recovering from unexpected exceptions; don’t try to use them for control flow.

Suspense is also hard to work with AbortController. At the point of writing it’s pretty much like a hack. For those who are interested, the code is here. I really want to find a better way for this. I’m curious how react-cache handle aborting. Until then, we continue to improve it.


Tag cloud