DEV Community

Cover image for Controlling React API Calls With Hooks
Adam Nathaniel Davis
Adam Nathaniel Davis

Posted on • Edited on

Controlling React API Calls With Hooks

I love React. But there are some aspects of the framework that have previously given me fits. One of those aspects is in the careful controlling of API calls from within a React app.

How many times have you witnessed this scenario?

You load a React app in the browser and, because you're a frontend developer, you find yourself opening the Inspector Tools and looking at the API (asynchronous) calls that are made from the app. That's when you notice something... fishy.

The app makes a simple GET request to some endpoint for some batch of basic data. Often, that data looks like it's the kind of data that rarely (if ever) changes. And yet... the app is making two, or three, or more(!) calls to the exact same endpoint. And in each of those calls, it's retrieving the exact same data.

Nearly every time I witness this, I know exactly why it's happening: Because the developers didn't understand how to properly control the API calls being launched from their own app!

To be fair, this is an extremely common fault in many React apps that I see. And it's so common for one very basic reason: React does a very poor job of guiding devs on how to make imperative calls. To put it more succinctly, React tends to gloss over the problem that arises when you need to perform a single operation, at a very specific time, and to ensure that this operation occurs ONLY ONCE.

By default, React doesn't really want you to think in imperative terms. It constantly pushes you to program in a declarative fashion. And to be clear, that's normally a very good thing. But there are SOME things that just don't fit cleanly into a declarative model - and an API call is definitely one of those scenarios.

This drives me nuts. Because there are certain API calls that really should only be performed once (or... under very specific conditions). So I deem it to be an act of "performance malpractice" when an app repeatedly calls for the same data - often before the user has had any opportunity to even interact with the data in any way.


Image description

Apollo Nightmares

Before I get into my solution, I wanna say a quick word about Apollo. This seems to be the "default" package that most devs reach for when they're managing GraphQL calls. And that's... ok. But IMHO, it has a major downfall: All of its default documentation tries to drive you to build your API calls declaratively. And for many different data calls, this is borderline-silly. (I wrote an entire article about this. You can read it here: https://dev.to/bytebodger/react-s-odd-obsession-with-declarative-syntax-4k8h)

Full Disclosure: It's entirely possible to manage your Apollo GraphQL calls imperatively. But you gotta spend a lot of time digging around their docs to figure out how to get it right. And this drives me crazy.

React's rendering cycle (driven by the reconciliation process) typically feels very "black box" to most devs. Even for a seasoned React dev, it can be difficult to say exactly when the render cycle will be invoked. And this is why I despise Apollo's default approach. Because API calls are definitely one aspect of your app that you should never be blindly handing over to the inner workings of React's reconciliation process. (I wrote an entire article about the reconciliation process. You can read it here: https://dev.to/bytebodger/react-s-render-doesn-t-render-1jc5)

So I'm not telling you to scrap Apollo (with its preferred declarative syntax). But if you're reading the rest of this tutorial and wondering, "Why don't you just use Apollo?" This is why. When I'm writing a responsive, asynchronous application, I've never found it satisfactory to simply surrender all of my API calls to the vagaries of the rendering cycle.


Image description

Just Use Saga

I'm pretty much on record as being a Redux Curmudgeon. (You can read my complete rant on the subject here: https://dev.to/bytebodger/the-splintering-effects-of-redux-3b4j) But I fully understand that many React shops are already thoroughly ensconced in Redux. So if your project already uses Redux, then I can safely say that you should be using Saga to manage your API calls. It's specifically designed to handle "side effects" and the first side effects it illustrates - right on its homepage - are API calls.

So if you're already well-versed with Redux Saga, I doubt I'm going to show you anything here that will trump that bit of entrenched technology. Use it. It's pretty cool.

But what if you're not already a "Redux shop"? And what if you don't wanna introduce all of Redux's built-in overhead just so you can cleanly manage a handful of API calls? Well... there's good news. You can do this quite simply with Hooks.


Image description

Forbidden Knowledge

OK... so I've said that this is "simple". But that doesn't necessarily mean that it's obvious. In fact, a few years ago I spent a great deal of time on the interwebs trying to figure out how to properly manage my API calls without invoking the demon that is Redux.

Sounds like a simple task, yeah? But strangely enough, the more I searched for the solution, the more exasperated I became with the solutions that I saw proposed on various sites and blogs. So I'm going to walk you through exactly how I manage API calls whenever I'm given the freedom to choose my own approach.


Image description

The Basic Setup

(Before I begin, you can see all this code, live-and-working, here: https://stackblitz.com/edit/react-px4ukm)

We're gonna start with a dead-simple React app structured like so:



/public
/src
  /common
    /functions
      get.axios.js
      load.shared.hooks.js
    /hooks
      use.reservations.endpoint.js
    /objects
      use.js
  App.js
  index.js
  Reservations.js
  UI.js
  package.json


Enter fullscreen mode Exit fullscreen mode

Obviously, you don't have to use my file structure. Rearrange as you see fit. This demo is built with create-react-app. Again, you obviously don't need to use that. This can be done in a custom Webpack build just fine. I'm going to start at the top of the app and just walk you through any pertinent points.

package.json



{
  "name": "react",
  "version": "0.0.0",
  "private": true,
  "dependencies": {
    "@toolz/use-constructor": "^1.0.1",
    "axios": "0.26.0",
    "react": "17.0.2",
    "react-dom": "17.0.2"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {
    "react-scripts": "latest"
  }
}


Enter fullscreen mode Exit fullscreen mode

Pretty standard stuff here. I'll only point out two features:

  1. I'm using my custom @toolz/use-constructor NPM package. (You can read all about it here: https://dev.to/bytebodger/constructors-in-functional-components-with-hooks-280m) You can write the functionality for this package out manually if you like. It just ensures that we can invoke an API call under a "traditional" constructor-like mindset - meaning that the code will run once and only once. That's where we're going to call our API.

  2. I'm using the Axios package to invoke asynchronous calls. You can use any approach that works for you - even if you're just doing "old-skool", plain-ol'-JavaScript asynchronous calls.

index.js

Move along folks. Nothing to see here. This is just the default index.js file that you get when you spin up a new Create React App. All it really does is invoke <App/>.

App.js



import React from 'react';
import { loadSharedHooks } from './common/functions/load.shared.hooks';
import { UI } from './UI';

export default function App() {
  loadSharedHooks();

  return <UI/>;
}


Enter fullscreen mode Exit fullscreen mode

I typically put almost no real "logic" in App.js. It merely serves as a launching point for the real app. In this component, I'm just calling <UI/>, and... I'm calling loadSharedHooks(). Here I'm using an approach that allows me to truly share global state between any/all components using nothing more than core React with Hooks. No Redux. No other third-party shared state package. Just... React Hooks. (You can read all about this approach in this article: https://dev.to/bytebodger/hacking-react-hooks-shared-global-state-553b)

/common/functions/load.shared.hooks.js



import { use } from '../objects/use';
import { useReservationsEndpoint } from '../hooks/use.reservations.endpoint';

export const loadSharedHooks = () => {
  use.reservationsEndpoint = useReservationsEndpoint();
};


Enter fullscreen mode Exit fullscreen mode

This is a dead-simple function. First, I create a custom Hook for every endpoint that I'll be hitting. And then I place a single instance (a "singleton") of that endpoint into the use object. This places the API calls outside the standard React reconciliation process. It allows me to control, with pinpoint accuracy, when any particular API call fires. It also allows me to then access the values from those APIs across all other components in the app.

It's important that I'm calling loadSharedHooks() right at the "top" of the app. By calling it there, I ensure that any endpoints I've loaded with loadSharedHooks() are readily available to me wherever/whenever I need them during the application's execution.

Wondering what's inside that use object? It looks like this:

/common/objects/use.js



export const use = {};


Enter fullscreen mode Exit fullscreen mode

That's it. That's the entire use.js file. It's just a plain ol' JavaScript object. The key is that, by invoking it at the top of the application, I can then reference the values inside use anywhere/anytime that I want. In this case, the Hook that manages the endpoint I'm hitting will be saved into use.

/common/hooks/use.reservations.endpoint.js



import { getAxios } from '../functions/get.axios';
import { useState } from 'react';

export const useReservationsEndpoint = () => {
  const [reservations, setReservations] = useState([]);
  const axios = getAxios();

  const loadReservations = async () => {
    const response = await axios.call(
      'GET',
      'https://cove-coding-challenge-api.herokuapp.com/reservations'
    );
    if (response.status === 200) setReservations(response.data);
  };

  return {
    loadReservations,
    reservations,
  };
};


Enter fullscreen mode Exit fullscreen mode

This code manages the single endpoint that we're using for this demo. The actual call is handled in loadReservations(). It leverages my custom axios wrapper. (I'm not going to outline the axios wrapper here. You can peruse it in the StackBlitz demo if you like. If this were a "full" app, I'd have functions inside the axios wrapper for POST, PUT, and PATCH operations. But for this simple demo, the wrapper only contains code for a GET call.)

Notice in this endpoint Hook that I only return the values for loadReservation and reservations. reservations contains the data that's returned from the endpoint. loadReservations() allows us to invoke the GET operation without needing to write out the full asynchronous code within the body of our components. setReservations is not returned. This keeps the downstream components from trying to update the endpoint values directly, without utilizing this custom Hook.

UI.js



import React from 'react';
import { useConstructor } from '@toolz/use-constructor';
import { use } from './common/objects/use';
import { Reservations } from './Reservations';

export const UI = () => {
  useConstructor(() => use.reservationsEndpoint.loadReservations());

  return <Reservations/>;
};


Enter fullscreen mode Exit fullscreen mode

<UI/> doesn't do much. On the surface, it just seems to call <Reservations/>. But there's one critical feature here: It leverages useConstructor() to load, once (and only once), the loadReservations() call. This ensures that we're not loading the reservations endpoint every time the app performs a re-render. Once that's been accomplished, it simply renders <Reservations/>.

Reservations.js



import React, { useState } from 'react';
import { use } from './common/objects/use';

export const Reservations = () => {
  const [index, setIndex] = useState(0);
  const reservationsEndpoint = use.reservationsEndpoint;

  const displayCurrentReservation = () => {
    if (reservationsEndpoint.reservations.length === 0)
      return null;
    const reservation = reservationsEndpoint.reservations[index];  
    return <>
      <br/>
      <div>
        Room Name: {reservation.room.name}
        <br/>
        Start Datetime: {reservation.start}
        <br/>
        End Datetime: {reservation.end}
      </div>  
      <br/>
    </>
  }

  const displayNextButton = () => {
    if (reservationsEndpoint.reservations.length === 0 || index ===  reservationsEndpoint.reservations.length - 1)
      return null;
    return <>
      <button onClick={() => setIndex(index + 1)}>
        Next
      </button>  
    </>  
  }

  const displayPreviousButton = () => {
    if (reservationsEndpoint.reservations.length === 0 || index === 0)
      return null;
    return <>
      <button 
        onClick={() => setIndex(index - 1)}
        style={{marginRight: 20}}
      >
        Previous
      </button>  
    </>  
  }

  return <>
    <div>
      {reservationsEndpoint.reservations.length} reservations found
    </div>  
    <div>
      Current showing reservation #{index}:
    </div>  
    {displayCurrentReservation()}  
    {displayPreviousButton()}
    {displayNextButton()}
  </>;
}


Enter fullscreen mode Exit fullscreen mode

Obviously, this is the "meat" of the application. Here's a quick synopsis of what it accomplishes:

  1. It sets a state variable for index, so we always know which reservation we're looking at.

  2. It accesses the reservationsEndpoint which was previously loaded with loadSharedHooks().

  3. It then displays the total number of reservations retrieved, the index of the current reservation, and some basic info about the reservation itself. It also shows Previous and Next buttons that allow you to cycle forward-or-backward through the existing reservations.


Image description

Takeaways

  1. If you open the Inspector Tools while viewing the StackBlitz demo, you'll see that the GET to the reservations endpoint is only ever called once. Even when you use the Previous or Next buttons, the GET call is never repeated, even though the state for <Reservations/> is updated and the component is repeatedly re-rendered.

  2. This was done without any third-party packages. No Redux (or Redux Saga). No Apollo. No other third-party state-management tools.

  3. The API call is never dependent upon the React reconciliation process, meaning that we used neither the lifecycle methods inherent in class-based components, nor the confusing mess of dependencies that are spawned with useEffect().

  4. The biggest takeaway I'd like you to embrace is that API calls should always be tightly controlled. Your app shouldn't be repeatedly calling the same endpoint for the same data.

Top comments (18)

Collapse
 
ecyrbe profile image
ecyrbe

Hello Adam,

As an alternative setup, one should also consider using react query.
You can control tighly how the stale-while-revalidate caching works and it comes packaged with sensible defaults.

Collapse
 
cliffordfajardo profile image
Clifford Fajardo • Edited

+1 about is react-query here.
React query is a lot simpler than apollo IMO, & is backend agnostic (REST, GQL, etc) & perhaps most importantly for getting started, its just a set of hooks 😄
I really do appreciate the code snippets in this article & thoughts 💭,. Personally, I wouldnt recommend the code in here production usage for a few reasons: There's probably a dozen+ of things that this doesn't handle (caching, request-deduping, cache access, etc), which something like react-query's hooks give you for free & its a very tiny library:

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

I def don't disagree with any of this. I just wanted to point out that it's entirely possible to responsibly manage your API calls without using any third-party package (and without blindly accepting that some of your basic data calls will simply need to be called repeatedly for no functional reason).

Thread Thread
 
cliffordfajardo profile image
Clifford Fajardo

Thanks for the clarification 🙏

Collapse
 
fadhilradh profile image
Fadhil Radhian

Agree to this. React Query is a godsend

Collapse
 
pelv profile image
Alex Benfaremo

I was going to ask him if he ever tried React Query. Great tool

Collapse
 
antonfil profile image
antonfil • Edited

Hi Adam,

Do I understand correctly that the whole App will be rerendered each time I call some api in some inner component instead of only rerendering that inner component? You call 'loadSharedHooks()' which is a hook which uses 'useState' internally in the global App.
The same question about article about 'Global State' which you refence to: dev.to/bytebodger/hacking-react-ho.... Does it also rerender the whole App while Redux rerenders only components with redux-properties have been changed?

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

This depends upon what you mean by "rerender". In fact, I find this subject causes so much confusion - even amongst senior React devs - that I wrote an entire article about it here: dev.to/bytebodger/react-s-render-d...

The synopsis is as follows:

If by "rerender" you mean that the DOM elements generated by the child components will fully re-render (as in, re-paint themselves in the screen), then no, they absolutely will not be 'rerendered". If the props provided to those child components remain unchanged, then there will be no updating of the DOM elements that are generated inside those child elements.

However, if you mean that the logic encapsulated in those child elements will be re-triggered, then yes, that will absolutely happen. A great way to illustrate this is with useEffect(). The logic inside useEffect() will be invoked every time that component is called (with the major caveat that you can stop this behavior by associating the proper set of dependencies in useEffect()). Some people look at this and say, "Aha! You see?? The child component did RERENDER!" But that's not what actually happened.

Every time the child component is invoked, React will spawn the reconciliation process. In other words, React will determine whether it needs to rerender the DOM. And if there is no change to the DOM, no rerendering will occur. But that doesn't mean that the logic in that component doesn't fire.

An even simpler way to illustrate this is to simply drop a console.log("This component was triggered."); into the body of the child component, then observe what happens when the state changes in the higher-level component. You'll see that the console.log() is indeed triggered. But even though it's been "triggered", this is NOT the same as saying that the child component was rerendered.

Collapse
 
antonfil profile image
antonfil

Thanks for the answer. I meant that the logic (including invoking 'render' method) of the entire <App/> component is retriggered and so Virtual DOM of the entire <App/> is regenerated and compared to Real DOM all the time. Don't you think it downgrades performance? With Redux the logic and regenerating of Virtual DOM happens only for affected components.

Thread Thread
 
bytebodger profile image
Adam Nathaniel Davis

Don't you think it downgrades performance?

No. This is taken from the article that I linked to above:

But don't take my word for it. This is directly from the React docs, on the same page that explains the Reconciliation process (emphasis: mine):

It is important to remember that the reconciliation algorithm is an implementation detail. React could rerender the whole app on every action; the end result would be the same. Just to be clear, rerender in this context means calling render for all components, it doesn’t mean React will unmount and remount them. It will only apply the differences following the rules stated in the previous sections.

Thread Thread
 
antonfil profile image
antonfil

If only re-rendering in the Real DOM had mattered to performance then why would React have introduced ‘memo’?

Thread Thread
 
bytebodger profile image
Adam Nathaniel Davis • Edited

Not sure what you're getting at. The React team is telling you, in their own documentation, that this is an "implementation detail". By definition, an "implementation detail" is something that those using the tool shouldn't be trying to concern themselves with. It's a decision that's supposed to be left to the purview of the tool itself. Worrying about implementation details is a micro-optimization. One of the greatest focuses of the React team is the optimization of the reconciliation process. And that team is telling you, in their own official documentation, that this is an implementation detail.

To be clear, I never said "only re-rendering the Real DOM matters to performance". There are many aspects of an application that can hinder performance - for example: slow, unnecessary, or inefficient API calls. But the reconciliation process is an algorithmic one. Worrying about how React works "under the sheets" - when their own team has specifically stated that this is an implementation detail - is like worrying over the performance of a for loop versus Array.prototype.forEach.

Collapse
 
jologe profile image
jologe

You saved me; I had been looking for ages for the reason why react called my API so many times and I couldn't find why. Thank you, trully.

Collapse
 
jzombie profile image
jzombie

This is somewhat unrelated, however, the few times I've used Apollo, it seems that the state would not maintain across development saves. I'm not sure if the issue was with Apollo or with the way things were set up, but it was really annoying.

Collapse
 
bytebodger profile image
Adam Nathaniel Davis

What do you mean by "across development saves"? Are you saying that you load the app, then run it with Apollo API calls, and then you change something in the app, and then run it again, and then the values are not saved?? Because, if that's what you're saying, that's the expected behavior for all asynchronous calls, regardless of the package you're using. You can save the values in some kinda temporary storage (e.g., Local Storage / Session Storage) and then try to intelligently determine whether you need to make the call again. But if you have an app that calls an API, it's going to make that call again every time you reload the app.

Collapse
 
jzombie profile image
jzombie • Edited

TLDR; The entire application would lose state when hot reloading.


If I am running a development version of the app, update a component and hit save, Apollo seems to bust its own local cache, regardless of where the component is in the tree.

Without using Apollo, create-react-app, Next.js, Vue, Svelte, all would show updates to that component in near real-time, regardless if there was an API call which supplied it with data, because typically the app state wouldn't be lost.

It seems w/ Apollo, the entire app would do a hard refresh so all of the components would lose their local state as well.

I'm sure it wasn't set up properly; perhaps it did need a persistence layer after all; just figured that it would have been able to maintain its own state across saves as well without using local / session storage.

Collapse
 
itshi32 profile image
itshi • Edited

What database could you advise for a react application for an on-time connection of 200 - 300 users?
Phone Directory Application
running in docker image as local web portal

Collapse
 
agnel profile image
Agnel Waghela

How about MongoDB?