Richard B. Kaufman-López

The easiest way to call APIs in React

August 29, 2018

A while ago I worked on a project that had a GraphQL API. On the frontend, I used a tool called React Apollo to communicate with that API. One of the features I loved most about it was how you would get an object for your query that came with loading, error, and data. These values would change over time during the life of a request. There’s much more to Apollo and GraphQL, but that feature was one of my favorites.

Now I’m working with a REST API. With React Hooks out, I knew I could easily and ergonomically reproduce this behavior. I ended up borrowing from the GraphQL terminology and created a hook I frequently use named useQuery.

useQuery is used to perform GET requests. It returns an object with the loading, error, and data properties.

You would use it like this:

function App() {
  const blogPosts = useQuery(() => fetch('/posts'))

  if (blogPosts.loading) {
    return <Loading />
  }

  if (blogPosts.error) {
    return <Error error={blogPosts.error}>
  }

  return (
    <Layout>
      <ul>
        {blogPosts.data.map(post => (
          <li key={post.id}>
            <a href={post.url}>
              {post.title}
            </a>
          </li>
        ))}
      </ul>
    </Layout>
  )
}

I love this because before you would have needed to use state and lifecycle methods in a Component class to implement the same behavior. Yes, we are still doing all that, but inside the useQuery hook. But this. This is very ergonomic. That’s my favorite feature about it.

Let’s take a look at how it’s implemented.

import React from 'react';

const FETCH = 'FETCH';
const SUCCESS = 'SUCCESS';
const ERROR = 'ERROR';

function reducer(state, action = {}) {
  switch (action.type) {
    case FETCH:
      return { ...state, loading: true, error: null };
    case SUCCESS:
      return { ...state, data: action.payload, loading: false };
    case ERROR:
      return { ...state, error: action.payload, loading: false };
    default:
      throw new Error('Unexpected action type.');
  }
}

export default function useQuery(query) {
  const [state, dispatch] = React.useReducer(reducer, {
    data: undefined,
    loading: true,
    error: null,
  });

  async function fetchData() {
    dispatch({ type: FETCH });
    try {
      const data = await query();
      dispatch({ type: SUCCESS, payload: data });
    } catch (error) {
      dispatch({ type: ERROR, payload: error });
    }
  }

  React.useEffect(() => {
    fetchData();
  }, []);

  return {
    data: state.data,
    loading: state.loading,
    error: state.error,
  };
}

It uses a reducer to change state. There are three actions: fetch start, fetch success, and fetch error. When useQuery is called, it starts fetching the data. It will automatically switch the state values as the fetch occurs.

I’ve found that this has made my code simpler to read and faster to write. I hope you find it useful on your projects, too.