Controlling React API Calls With Hooks
Adam Nathaniel Davis
Posted on March 5, 2022
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.
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.
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.
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.
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
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"
}
}
Pretty standard stuff here. I'll only point out two features:
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.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/>;
}
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();
};
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 = {};
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,
};
};
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/>;
};
<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()}
</>;
}
Obviously, this is the "meat" of the application. Here's a quick synopsis of what it accomplishes:
It sets a state variable for
index
, so we always know which reservation we're looking at.It accesses the
reservationsEndpoint
which was previously loaded withloadSharedHooks()
.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
andNext
buttons that allow you to cycle forward-or-backward through the existing reservations.
Takeaways
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 thePrevious
orNext
buttons, theGET
call is never repeated, even though the state for<Reservations/>
is updated and the component is repeatedly re-rendered.This was done without any third-party packages. No Redux (or Redux Saga). No Apollo. No other third-party state-management tools.
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()
.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.
Posted on March 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.