Skip to content

Instantly share code, notes, and snippets.

@dstroot
Forked from elierotenberg/BLOG.md
Created November 1, 2019 03:34
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dstroot/806fffb6b006b1a68e40d328530c522e to your computer and use it in GitHub Desktop.
Save dstroot/806fffb6b006b1a68e40d328530c522e to your computer and use it in GitHub Desktop.
Idiomatic Data Fetching using React Hooks

Idiomatic Data Fetching using React Hooks

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;
}

1. Low-level integration

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?

2. Isolating data fetching code from UI code

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.

3. Composite hooks for better encapsulation

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.

4. Implementing caching and request de-duplication

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.

5. Wrap-up

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment