Blog Overview

Connect React Application to your API Backend

A reliable and scalable way for building React applications and hooking them up to your (REST) API backend.

March 8th, 2022

When building a React application, you'll most likely want to connect to a (REST) API backend to fetch and update data. This can easily be achieved by placing some calls to "fetch()" in the correct lifecycle methods. But integrating the API in a scalable and easy-to-use manner is a different story. For example when performing authentication, oftentimes an API token needs to be passed with every request. Having to manually construct each request, setting headers etc. and passing the API token, can be a real hassle. Furthermore, you'll need to write the same code over and over again. This post will teach you how to build extensible front-end infrastructure for connecting to an API backend, including handling authentication and translating errors (the latter will be covered in a future post) in a centralized way.

To give you some intuition to what we're building, we'll start with an example of how to use the backend connection. Below, we're building a StartPage component that displays a welcome message along with all of the user's blog posts if he is authenticated and a link to the signin page otherwise. The blog posts will here be simulated by some simple text.

In line 8, we use our custom useAPI() hook to get access to the user object and to the fetchPosts() function. We can use the user object to check whether the user is authenticated (as is done in line 11 and line 27). Further down, in line 15, we then use the fetchPosts() function to fetch the list of posts for the user. If an error occurrs, it can simply be handled by displaying the translated error message.

components/StartPage.js

import React from "react";

import { useAPI } from "services/api";

const StartPage = function StartPageComponent({}) {
  const [posts, setPosts] = useState([]);

  const { user, fetchPosts } = useAPI();

  useEffect(() => {
    if (!user) return;

    let posts;
    try {
      posts = fetchPosts({ userID: user.id });
    } catch (err) {
      /** Errors should be displayed to the user, e.g. using toast messages. */
      err.messages.map((msg) => console.err(msg));
      return;
    }

    setPosts(posts);
  }, []);

  return (
    <div>
      {user ? (
        <>
          <div>Welcome, {user.username}</div>
          <div>{posts && posts.map((post) => <div>{post.message}</div>)}</div>
        </>
      ) : (
        <span>
          Please <a href="/signin">Sign In</a>.
        </span>
      )}
    </div>
  );
};

Now, to start out, we will create a React context object. The context allows us to pass data through the component tree and have access to it anywhere in our React application. You can read more about the React context at https://reactjs.org/docs/context.html.

services/api/APIContext.js

import React from "react";

const APIContext = React.createContext();

export default APIContext;

Next, we will create a custom context provider. The provider keeps the required application state and performs initialization and cleanup. For our purposes, we store an access token to authenticate against the API and a user object to keep some basic information about the currently authenticated user. The access token can later be set by some signin component (which we won't build here).

services/api/APIProvider.js

import React, { useEffect, useState } from "react";

import APIContext from "./APIContext";

const APIProvider = function APIContextProviderComponent({ children }) {
  /** Store accessToken to authenticate API requests. */
  const [accessToken, setAccessToken] = useState(null);
  /** Store some info about currently authenticated user. */
  const [user, setUser] = useState(null);

  const context = { accessToken, setAccessToken, setUser, user };

  useEffect(async () => {
    /** Cleanup if accessToken has been reset. */
    if (!accessToken) {
      setUser(null);
    }
  }, [accessToken]);

  return (
    <APIContext.Provider value={context}>{children}</APIContext.Provider>
  );
};

export default APIProvider;

To make requests to the API, we build a custom makeRequest() function. This function handles setting all the correct request parameters, making the request, and handling some rudimentary errors. To make the requests, we will use the Axios library which you can read about more at https://axios-http.com.

We first build the request URI in lines 16 to 18 by appending the route (e.g. "/user") to the base URI. This way, we have the base URI in a centralized place. We then set up the request headers in lines 20 to 25. This includes the authorization header through which we pass the access token to the API. If authentiation in your API works differently, the makeRequest function is where you can configure the required behaviour. In line 27 we construct the options object to be passed to axios and in line 31 we finally make the API request. If the API request fails, we can handle the error in lines 33 to 39. This is also the place where we will later add translations for the API errors.

services/api/makeRequest.js

import axios from "axios";

/** Base URI of the API backend server. */
const BASE_URI = "127.0.0.1";

/** Specify default headers to use. */
const defaultHeaders = {};

/**
 * Makes an API request and returns the data returned by the API.
 * context: The API context.
 * route: A string specifying the route to make a request to.
 * options: An object of options.
 * options.data: The data to pass in the body of the request.
 * options.headers: Headers to set on the request.
 * options.method: The HTTP method for the request.
 * options.params: The data to pass in the GET query of the request.
 */
const makeRequest = async function makeAPIRequest(
  context,
  route,
  { data = undefined, headers = {}, method = "GET", params = undefined }
) {
  const { accessToken } = context;

  /** Build request URI. */
  const trimmedRoute = route.replace(/^\//, "");
  const url = `${BASE_URI}/${trimmedRoute}`;

  const augmentedHeaders = {
    /** Add access token to headers to authenticate against the API. */
    Authorization: accessToken ? `Bearer ${accessToken}` : undefined
    ...defaultHeaders,
    ...headers
  };

  const options = { data, params, headers: augmentedHeaders, method, url };

  let response;
  try {
    response = await axios(options)
  } catch (err) {
    let apiError = { message: "An unknown error has occurred." };
    
    // TODO: handle API error.
    // set apiError = { message: "Some error message", ... }
    // or better yet, create a custom error class

    throw apiError;
  }

  return response.data;
};

Once we have created the makeRequest() function, we can start creating functions for querying our API endpoints. We start with a function to authenticate against the API. This function should send a username and password to the API and, if they are valid, return an access token for further requests as well as some data on the authenticated user. The implementation is quite straightforward and is more of a wrapper around the previously defined makeRequest() function. We will want to create such wrappers for each of our API's endpoints.

services/api/auth/authenticate.js

import makeRequest from "./makeRequest";

const authenticate = async function authenticateService(context, { password, username }) {
  const uri = "/auth";
  const method = "POST";
  const data = { password, username };

  let response;
  try {
    response = await makeRequest(context, uri, { method, data });
  } catch (error) {
    // todo: handle error if needed
    throw error;
  }

  const { accessToken, user } = response;
  context.setAccessToken(accessToken);
  context.setUser(user);

  return null;
};

export default authenticate;

We further create a function to fetch some posts for a given user. Note how we use params instead of data in line 10. This is because we are making a GET request and must specify the data to be passed accordingly. On IOS devices for React Native, this is a common source of error as specifying a body on a GET request is invalid and will cause the OS to abort the request.

services/api/posts/fetchPosts

import makeRequest from "./makeRequest";

const fetchPosts = async function fetchPostsService(context, { userID }) {
  const uri = "/posts";
  const method = "GET";
  const params = { userID };

  let response;
  try {
    response = await makeRequest(context, uri, { method, params });
  } catch (error) {
    // todo: handle error if needed
    throw error;
  }

  return response.data;
};

export default fetchPosts;

The final function needed is the context consumer. This function makes sure all the request functions are hooked up to the API context and therefore have access to the access token, the user, and whatever else you may want to add.

services/api/useAPI.js

import React from "react"

import APIContext from "./APIContext";
import authenticate from "./auth/authenticate.js";
import fetchPosts from "./posts/fetchPosts.js";

const useAPI = () => {
  const context = React.useContext(APIContext);

  if (context === undefined)
    throw new Error("useAPI must be used within an APIProvider");

  const bindContext = useCallback(
    (services) =>
      Object.fromEntries(
        Object.entries(services).map(([key, service]) => [
          key,
          (...args) => service(context, ...args),
        ])
      ),
    [context]
  );

  /**
   * If you have many services e.g. exported from a index.js file,
   * you can also import them as a list. This is when the bindContext
   * function really shines.
   */
  const augmentedContext = {
    ...context,
    ...bindContext([authenticate]),
    ...bindContext([fetchPosts]),
  };

  return augmentedContext;
};

At last, don't forget to add the Provider component to the component tree.

App.js

import React from "react";

import APIProvider from "services/api/APIProvider";

/** React app entry point. */
const App = function AppComponent() {
  return (
    <APIProvider>
      { /** other components go here ... */ }
    </APIProvider>
  );
};

export default App;

That's it, now you have a working connection to your backend that you can use as shown in the introductory example. And if you want to make changes to the infrastructure in the future, adding functionality or changing endpoints, it will be easy as all the code is centralized.