This post has been written in collaboration with @klervicn
Virtually all web apps and websites need to pull data from a server, usually through a JSON-returning API. When it comes to integrating data fetching in React component, the "impedence mismatch" between the React components, which are declarative and synchronous, and the HTTP requests, which are imperative and asynchronous, is often problematic.
Many apps use third-party libraries such as Redux or Apollo Client to abstract it away. This requires extra dependencies, and couple your app with a specific library to perform data fetching. In most cases, what we want is a direct way to integrate plain HTTP requests (e.g. using native fetch) for usage in React components.
Here we will discuss how we can use React Hooks to do this in an elegant, scalable manner.
To illustrate this we are going to build a component that fetches a blog post from an imaginary API and renders it:
interface IPost {
title: string;
date: Date;
body: string;
}
Our first attempt will be to directly use fetch
from a function component and update its state when the request ends (with either a result or an error).
First we need to lay down a synchronous abstraction of an ongoing asynchronous fetch. To this we will use a simple, very common projection - a state object which can have 3 substates:
pending
,rejected
with an error,resolved
with a value.
In TypeScript, we would declare it like this:
type FetchState<T> =
| { state: "pending"; }
| { state: "resolved"; value: T }
| { state: "rejected"; error: Error };
We will use two hooks to fetch data and use the result:
useState
will wrap the current fetch state,useEffect
will trigger the actual request and dispatch state updates when the request is done.
const Post = ({ postId, baseURL }) => {
const [post, setPost] = useState<FetchState<IPost>>({ state: "pending" });
useEffect(() => {
fetch(`${baseURL}/post/${postId}`)
.then(response => response.json())
.then(value => setPost({ state: "resolved", value }))
.catch(error => setPost({ state: "rejected", error }));
}, [postId, baseURL]);
if(post.state === "pending") {
return <div>Loading...</div>;
}
if(post.state === "rejected") {
return <div>Error: <pre>{JSON.stringify(error, null, 2)}</pre></div>
}
return <div>
<h1>{post.state.value.title}</h1>
<div>{post.state.value.date.toLocaleString()}</div>
<div>{post.body}</div>
</div>;
}
This works perfectly fine, but this won't scale very well.
What happens when we want to fetch posts from other components?
What happens if we change the URLs?
What happens if we change the return format?
How do we test this code against a fake API?
Most crucially, is it a good idea to perform data fetching from a React component, whose primary responsibility should be to synchronously emit UI elements?
Instead of having the data fetching code inside React components we will move it into a dedicated class. This way we can have all the data-fetching code in the same module and maintain it separately from the UI bits handled by the components themselves. This also makes the components easier to test, since we can swap the "real" implementation and a mocked implementation which doesn't perform any actual HTTP requests.
This could look like this:
class FetchClient {
baseURL: string;
constructor(baseURL: string = "") {
this.baseURL = baseURL;
}
getPost = async (postId: string) => {
const response = await fetch(`${this.baseURL}/posts/${postId}`);
return await response.json();
}
}
And we can make an instance available to all components using Context
:
const FetchClientContext = createContext<FetchClient>(new FetchClient());
const App = ({ baseURL }) => {
const fetchClient = useMemo(() => new FetchClient(baseURL), []);
return <FetchClientContext.Provider value={fetchClient}>
// ...
</FetchClientContext>;
}
With this setup, we can rewrite the code for the React component:
const Post = ({ postId }) => {
const fetchClient = useContext(FetchApiClient);
const [post, setPost] = useState<FetchState<IPost>>({ state: "pending" });
useEffect(() => {
fetchApiClient.getPost(postId)
.then(value => setPost({ state: "resolved", value }))
.catch(error => setPost({ state: "rejected", error }));
}, [fetchApiClient, postId]);
if(post.state === "pending") {
return <div>Loading...</div>;
}
if(post.state === "rejected") {
return <div>Error: <pre>{JSON.stringify(error, null, 2)}</pre></div>
}
return <div>
<h1>{post.state.value.title}</h1>
<div>{post.state.value.date.toLocaleString()}</div>
<div>{post.body}</div>
</div>;
}
This looks better and more testable but for now we have added a lot of code and we still handle a lot of things at the React component level. We will still duplicate all the useState / useEffect
stuff in every component that needs to fetch data from our API.
One great thing with hooks is that they are composable. We can define our own hooks which will, under the hood, use basic hooks such as useState
and useEffect
. Hooks are resolved at the call site, which means that whener we call a function which uses useState
or useEffect
, it will be just the same as calling them directly from the component.
This allows use to define a wrapper class for our FetchApi
class:
class FetchClientHooks {
fetchClient: FetchClient;
constructor(fetchClient: FetchClient) {
this.fetchClient = fetchClient;
}
getPost = (userId) => {
const [post, setPost] = useState<FetchState<IPost>>({ state: "pending" });
useEffect(() => {
this.fetchApiClient.getPost(postId)
.then(response => response.json())
.then(value => setPost({ state: "resolved", value }))
.catch(error => setPost({ state: "rejected", error }));
// this.fetchApiClient will actually never change but we
// still make it an explicit dependency for clarity
}, [this.fetchApiClient, userId]);
return post;
}
}
This will greatly simplify our React component:
const Post = ({ postId }) => {
const fetchClientHooks = useContext(FetchClientHooksContext);
// "useState" and "useEffect" in getPost will be resolved
// as if they were called directly in the component
const post = fetchClientHooks.getPost(userId);
if(post.state === "pending") {
return <div>Loading...</div>;
}
if(post.state === "rejected") {
return <div>Error: <pre>{JSON.stringify(error, null, 2)}</pre></div>
}
return <div>
<h1>{post.state.value.title}</h1>
<div>{post.state.value.date.toLocaleString()}</div>
<div>{post.body}</div>
</div>;
}
The code looks much better now. We have clearly separated the data fetching part from the UI rendering part.
What happens when multiple components in the same app want the same data? With the current implementation, each component would have to refetch data from the server. We will try to improve our implementation to handle caching and concurrent access to the same asynchronous data store.
Our target behaviour would be:
- When a component needs data, and if this data isn't already being fetched or already fetched, then actually fetch the data
- Otherwise, re-use the data
The tricky part here is that we are dealing with asynchronous stuff. We need to be able to have multiple components require the same data, whether this data is already available or is currently pending.
To do this, we will need to have:
- a storage for currently available or pending data,
- a way to notify all consumers for this data that it has changed state (from "pending" to "resolved" or "rejected").
As it turns out, we can do this without changing a single line of our React components, and still use the exact same code:
const Post = ({ postId }) => {
const fetchClientHooks = useContext(FetchClientHooksContext);
const post = fetchClientHooks.getPost(userId);
if(post.state === "pending") {
return <div>Loading...</div>;
}
if(post.state === "rejected") {
return <div>Error: <pre>{JSON.stringify(error, null, 2)}</pre></div>
}
return <div>
<h1>{post.state.value.title}</h1>
<div>{post.state.value.date.toLocaleString()}</div>
<div>{post.body}</div>
</div>;
}
We will use an intermediate abstraction layer, which will hold a FetchState
value and will notify all subscribing components whenever it changes state. We will also provide a callback for when no components needs it anymore so we can remove it from memory.
class FetchStore<T> {
// How to actually fetch data
fetch: () => Promise<T>;
onRemoveLastListener: () => void;
fetchState: FetchState<T>;
listeners: Set<(fetchState: FetchState<T>) => void>;
public constructor(
fetch: () => Promise<T>,
onRemoveLastListener: () => void,
) {
this.fetch = fetch;
this.onRemoveLastListener = onRemoveLastListener;
this.fetchState = { state: "pending" };
this.listeners = new Set();
}
setFetchState = (fetchState: FetchState<T>): void => {
// Whenever state is updated, notify all listeners
this.fetchState = fetchState;
for (const listener of this.listeners) {
listener(fetchState);
}
};
use = (): FetchState<T> => {
// Use current state as initial local state
const [state, setState] = useState<FetchState<T>>(this.fetchState);
useEffect(() => {
// Whenever fetchState is updated, also update local state
this.listeners.add(setState);
// the first time we add a listener, trigger the actual fetch
if (this.listeners.size === 1) {
this.fetch()
.then(value => this.setFetchState({ state: "resolved", value }))
.catch(error => this.setFetchState({ state: "rejected", error }));
}
return () => {
this.listeners.delete(setState);
// Call onRemoveLastListener if this was the last listener
if (this.listeners.size === 0) {
this.onRemoveLastListener();
}
};
// The dependencies will actually never change but we still
// make them explicit for clarity
}, [this.listeners, this.fetch, this.onRemoveLastListener]);
return state;
};
}
Now when we call getPost
from FetchClientHooks
, we will use a cache object.
class FetchClientHooks {
fetchClient: FetchClient;
posts: Map<string, FetchStore<IPost>>;
constructor(fetchClient: FetchClient) {
this.fetchClient = fetchClient;
// cache is initially empty
this.posts = new Map();
}
getPost = (postId: string) => {
const previousStore = this.posts.get(postId);
// If the store already exists, hook into it
if(previousStore) {
return previousStore.use();
}
// Otherwise, create it
const nextStore = new FetchStore(
() => this.fetchClient.getPost(postId),
// remove the cache entry when there are no more listeners
() => {
this.posts.delete(postId);
}
);
this.posts.set(postId, nextStore);
return nextStore.use();
}
}
Note that the underlying hooks (useState
and useEffect
) are always called in the exact same order, regardless of whether we had to create the instance or not. This is required for hooks to function properly, as they must be called inconditionally in the same order everytime the component is re-rendered.
In this post we have shown how to use hooks to perform data-fetching in React components. Then we have shown how to properly extract the data-fetching code from the React components to have a better isolation, thus more testable and maintanable code, all without using an external library. Finally we have illustrated how having separate concerns has allowed us to add caching without having to modify the React components.
In an upcoming post, we will tackle another common problem we have to deal with when interfacing a React client with an API server: API calls with side effects (e.g. editing a post or creating a new one) and change-propagation accross the app.
We hope you have liked this post, please feel free to comment below!
🦦🦦
See this post on Hackernews and on /r/reactjs