Organizing network calls in Web Apps with Concentric Call Methodology
Table of Contents: Intro Setting up the files Documentation Each API gets a file Proxies and Environments Verbs live in one place Non-network data Benefits In conclusion Intro As a UI Architect one common problem space I've noticed across teams and repos is how to organize network calls in web apps. Without a plan in place, you are likely to naively just call axios or fetch from any random component, JS file, or global store. Or you may try moving all network calls into your global stores so at least there is some consistency and it gets them out of the components. We tend to think of network calls as just a simple function that has two results (success/failure). This thought process can lead to underestimating the need for putting in the effort to organize them better. You may easily end up with a ton of network calls spread across a codebase with repeated boilerplate and imports copy/pasted around. Not great! But fret not, you are are not alone! Below is a methodology you can adopt for new codebases, or start migrating existing codebases over to at your own pace. The teams where I work that have switched to this have all enjoyed it and much preferred it to their own past attempts. This encouragement from them is why I've wanted to document it here for others. I've named it the "Concentric Call Methodology", which will be explained at the end. The Setup First create these files: /src/data/README.md /src/data/localStorage.js /src/data/apis/users.js /src/services/index.js /src/services/verbs.js Below we will go over what goes in each file, and why they are split up this way. Documentation The /src/data/README.md is a very important document. As with all programming patterns, if the tools (language/framework/linter/etc) aren't enforcing organization, then it must be communicated and enforced socially. This document clearly and succinctly conveys the approach to anyone working in your codebase. # Concentric Call Methodology ## Data/Network Call Organization * Network calls are organized into subfolders for each API. * All network calls go through the main GET, PUT, POST, PATCH, DELETE verb functions. * Located here: `src/services/verbs.js` - You should almost never need to touch these. * API specific files, like `src/data/apis/users.js`, will have 3 sections: 1. **Private verb functions** only to be used by that file. 1. **Endpoint functions** that can be called from anywhere and always return the **exact data** from the endpoint. 1. **Transform functions** that modify data before or after calling an endpoint function. Non-network related functions dealing with data can also be stored in the `src/data` folder, such as `localStorage.js`. Each API gets a file Assuming your app has many different API's it hits, you would have several files in the src/data/apis folder. Where I work, all our apps hit the same "users" API. So we'll use that as an example of what files would look like in this folder: /src/data/apis/users.js import { usersApi } from '@/services/index.js'; import { createApiVerbs } from '@/services/verbs.js'; /** * PRIVATE: DO NOT EXPORT. */ const usersVerbs = createApiVerbs(usersApi); /** * ENDPOINTS */ /** * Get the currently logged in user's User object. * * @param {object} params Any URL params * @return {object} A user object or empty object */ export function getCurrentUser (params) { return usersVerbs.GET({ url: '/current', options: { params }, message: 'Error getting user account.', defaultReturn: {} }); } /** * Get a list of all admins as user objects. * * @param {object} params Any URL params * @return {object[]} Array of admin user objects or empty array */ export function getAdmins (params) { return usersVerbs.GET({ url: '/admins', options: { params }, message: 'Error getting list of admins.', defaultReturn: [] }); } /** * TRANSFORMS */ /** * Get a list of the last names of super users. * * @param {object} params Any URL params * @return {string[]} Array of super admin last names or empty array */ export const getSuperAdminLastNames = async function (params) { const admins = await getAdmins(params); return admins .filter((admin) => { return admin.super; }) .map((admin) => { return admin.lastName; }); } Things to note in this example: We are exporting a simple function for each endpoint used in the app. If we need fresh data, but it needs mutated in some way, then we call a transform, so mutating of data never happens within the components/stores, but explicitly when obtaining it. From a "functional programming" perspective, this means we minimize the amount of time this data is mutable. From the apps perspective, calling a transform looks and feels identical to calling an endpoint. This is intentional. We isolate where the difference between the tw

Table of Contents:
- Intro
-
Setting up the files
- Documentation
- Each API gets a file
- Proxies and Environments
- Verbs live in one place
- Non-network data
- Benefits
- In conclusion
Intro
As a UI Architect one common problem space I've noticed across teams and repos is how to organize network calls in web apps.
Without a plan in place, you are likely to naively just call axios
or fetch
from any random component, JS file, or global store. Or you may try moving all network calls into your global stores so at least there is some consistency and it gets them out of the components.
We tend to think of network calls as just a simple function that has two results (success/failure). This thought process can lead to underestimating the need for putting in the effort to organize them better. You may easily end up with a ton of network calls spread across a codebase with repeated boilerplate and imports copy/pasted around. Not great! But fret not, you are are not alone!
Below is a methodology you can adopt for new codebases, or start migrating existing codebases over to at your own pace. The teams where I work that have switched to this have all enjoyed it and much preferred it to their own past attempts. This encouragement from them is why I've wanted to document it here for others.
I've named it the "Concentric Call Methodology", which will be explained at the end.
The Setup
First create these files:
/src/data/README.md
/src/data/localStorage.js
/src/data/apis/users.js
/src/services/index.js
/src/services/verbs.js
Below we will go over what goes in each file, and why they are split up this way.
Documentation
The /src/data/README.md
is a very important document. As with all programming patterns, if the tools (language/framework/linter/etc) aren't enforcing organization, then it must be communicated and enforced socially. This document clearly and succinctly conveys the approach to anyone working in your codebase.
# Concentric Call Methodology
## Data/Network Call Organization
* Network calls are organized into subfolders for each API.
* All network calls go through the main GET, PUT, POST, PATCH, DELETE verb functions.
* Located here: `src/services/verbs.js` - You should almost never need to touch these.
* API specific files, like `src/data/apis/users.js`, will have 3 sections:
1. **Private verb functions** only to be used by that file.
1. **Endpoint functions** that can be called from anywhere and always return the **exact data** from the endpoint.
1. **Transform functions** that modify data before or after calling an endpoint function.
Non-network related functions dealing with data can also be stored in the `src/data` folder, such as `localStorage.js`.
Each API gets a file
Assuming your app has many different API's it hits, you would have several files in the src/data/apis
folder. Where I work, all our apps hit the same "users" API. So we'll use that as an example of what files would look like in this folder:
/src/data/apis/users.js
import { usersApi } from '@/services/index.js';
import { createApiVerbs } from '@/services/verbs.js';
/**
* PRIVATE: DO NOT EXPORT.
*/
const usersVerbs = createApiVerbs(usersApi);
/**
* ENDPOINTS
*/
/**
* Get the currently logged in user's User object.
*
* @param {object} params Any URL params
* @return {object} A user object or empty object
*/
export function getCurrentUser (params) {
return usersVerbs.GET({
url: '/current',
options: { params },
message: 'Error getting user account.',
defaultReturn: {}
});
}
/**
* Get a list of all admins as user objects.
*
* @param {object} params Any URL params
* @return {object[]} Array of admin user objects or empty array
*/
export function getAdmins (params) {
return usersVerbs.GET({
url: '/admins',
options: { params },
message: 'Error getting list of admins.',
defaultReturn: []
});
}
/**
* TRANSFORMS
*/
/**
* Get a list of the last names of super users.
*
* @param {object} params Any URL params
* @return {string[]} Array of super admin last names or empty array
*/
export const getSuperAdminLastNames = async function (params) {
const admins = await getAdmins(params);
return admins
.filter((admin) => {
return admin.super;
})
.map((admin) => {
return admin.lastName;
});
}
Things to note in this example:
- We are exporting a simple function for each endpoint used in the app.
- If we need fresh data, but it needs mutated in some way, then we call a transform, so mutating of data never happens within the components/stores, but explicitly when obtaining it. From a "functional programming" perspective, this means we minimize the amount of time this data is mutable.
- From the apps perspective, calling a transform looks and feels identical to calling an endpoint. This is intentional. We isolate where the difference between the two matters exclusively to this file to reduce mental overhead throughout the rest of the app.
- If you are doing a PUT, POST, or PATCH, you may want to send your data into a transform to mutate it into the shape the endpoint wants before calling to the network function. This allows the component code to work with the data in the shape that makes sense for the UI, and to offload the needs of the API to a transform function.
- There are some imports at the top of this file, we'll look at those next.
- For APIs where you hit a lot of endpoints, you may want to create a folder with many files in it. Example:
/src/data/apis/users/foo.js
.
Proxies and Environments
For your app, you may deploy to multiple environments (DEV, TEST, PROD), and you may have endpoints you want to hit in your app that you need to proxy against when running your frontend locally.
The /src/services/index.js
exists to create the base URL for each endpoint you will hit. It deals with the environments & proxies for these endpoints. It will return the correct URL for them during runtime for you to use.
import { ProxyMap, urlBuilder } from '@my-company/network-utilities';
const proxyMap = new ProxyMap();
export const inventoryAppUrl = urlBuilder('inventory-api');
export const usersApi = proxyMap.addHostname('users-api');
Where I work, we have a shared Network Utilities library so all apps can import common functions for our environments/proxies/auth.
You may need to write your own helper functions for these, but they will be specific to your environment, so I can't help you with that. Feel free to either keep them in his /src/services/index.js
file or store them in your own networkUtilities.js
and import them in.
Verbs live in one place
This is the most important part of this organizational structure. Ultimately, there is only ONE function in your entire app that makes a GET network call, and all GET requests must funnel through it. The same is true for all other verbs (PUT, POST, PATCH, DELETE).
This means you have a singular point in your app where you can globally change anything about network calls.
/src/services/verbs.js
import axios from 'axios';
import { globalAlertsStore } from '@/stores/globalAlerts.js';
/**
* The global GET network utility function. All GET calls in the app
* will pass through this function.
*
* @param {object} options
* @param {string} options.url The URL to call
* @param {object} options.options Axios options
* @param {string} options.message Message to display if an error occurs
* @param {any} options.defaultReturn Generally an empty object or array, returned if network call fails
*/
export async function GET ({ url, options, message, defaultReturn }) {
if (!url || !message || !defaultReturn) {
console.error('ERROR: All of the following are required:', { url, message, defaultReturn });
}
const optionsWithCredentials = {
...options,
withCredentials: true
};
try {
const response = await axios.get(url, optionsWithCredentials);
return response.data || defaultReturn;
} catch (error) {
globalAlertsStore().addAlert({ message, error });
return defaultReturn;
}
}
/**
* The global POST network utility function. All POST calls in the app
* will pass through this function.
*
* @param {object} options
* @param {string} options.url The URL to call
* @param {object} options.body Axios body
* @param {object} options.options Axios options
* @param {string} options.message Message to display if an error occurs
* @param {any} options.defaultReturn Generally an empty object or array, returned if network call fails
*/
export async function POST ({ url, body, options = {}, message, defaultReturn }) {
if (!url || !message || !defaultReturn) {
console.error('ERROR: All of the following are required:', { url, message, defaultReturn });
}
const optionsWithCredentials = {
...options,
withCredentials: true
};
try {
const response = await axios.post(url, body, optionsWithCredentials);
return response.data || defaultReturn;
} catch (error) {
globalAlertsStore().addAlert({ message, error });
return defaultReturn;
}
}
/**
* Creates API specific GET/POST/PATCH/PUT/DELETE verbs. These are basically
* passthrough methods that prefix a provided base url to endpoints.
*
* @param {string} baseUrl The part of the url prior to the endpoint.
* @return {object} Object with API specific GET, POST, DELETE, etc methods
*/
export function createApiVerbs (baseUrl) {
return {
/**
* All GET calls for a specific API go through this function.
* @param {object} options
* @param {string} options.url The endpoint
* @param {object} options.options Axios options
* @param {string} options.message Human readable error message
* @param {(object|array)} options.defaultReturn Empty array or object
* @return {(object|array)} The data or defaultReturn
*/
GET: function ({ url, options, message, defaultReturn }) {
if (!url) {
console.error('ERROR: URL is required', { url });
}
return GET({ url: baseUrl + url, options, message, defaultReturn });
},
/**
* All POST calls for a specific API go through this function.
* @param {object} options
* @param {string} options.url The endpoint
* @param {object} options.body Axios body
* @param {object} options.options Axios options
* @param {string} options.message Human readable error message
* @param {(object|array)} options.defaultReturn Empty array or object
* @return {(object|array)} The data or defaultReturn
*/
POST: function ({ url, body, options, message, defaultReturn }) {
if (!url) {
console.error('ERROR: URL is required', { url });
}
return POST({ url: baseUrl + url, body, options, message, defaultReturn });
}
};
}
This is a long file for a code example, so let's break it down.
- We are importing
axios
, a library for making network calls. It's very convenient, simplifying working with network calls, and makes mocking network calls in unit tests very easy. But you can import whatever you want, or just use the nativefetch
API. It doesn't matter. - Next we import our global alerts store, when adding alerts here, they will show up as toast messages in the UI. You will want to change this for however you handle alerts in your app.
- Next we have a GET and POST example. I didn't do all of them, but I think you get the point and can flesh these out as you need them.
- The GET and POST examples will always return either the data from the network response, or the
defaultReturn
, which is just an empty data type matching what the API would return. Generally APIs for a single record return an object, so thedefaultReturn
would be{}
, but for APIs returning many records you'd probably want[]
for thedefaultReturn
. -
Note: You don't have to use the
defaultReturn
approach at all! For our apps, this approach works well, and when you callgetSomeRecord()
you know you'll always get data back, and don't need to worry about error handling at all, because the error is handled in the globalGET
function and added as a toast. So your code just becomes:const user = await getCurrentUser();
. And that's it! But if you want to return promises instead and deal with them at a higher level, you can do that too. - Notice the
withCredentials: true
, we found a weird error/bug with our network calls and realized addingwithCredentials: true
was all that was needed to fix it. Because we organized our code this way, we only had to add it to the global Verb functions, rather than all over the place. - The
createApiVerbs
helper lives here too. You've already seen it in use in theusers.js
code example. - Notice how each function here does runtime type checking of the inputs they expect, but only of the inputs they actually use themselves. If a function is primarily just a passthrough, it doesn't worry about the other arguments, it just passes them along.
Non-network data
Though this approach is primarily focused on organizing network calls, we created a data
folder, rather than a network
folder on purpose. Your app shouldn't care where the data it is getting comes from. So below we have one more example of retrieving and storing data, this time via localStorage
in the /src/data/localStorage.js
file.
/**
* This lists serves as documentation of all the current localStorage keys.
* Forcing us to add values here helps prevent potential key overlap.
*/
const localStorageKeys = Object.freeze([
'appTheme'
]);
export function setLocalStorageValue (key, value) {
if (!localStorageKeys.includes(key)) {
throw new Error('Invalid setLocalStorageValue key: ' + key);
}
localStorage.setItem(key, value);
}
export function getLocalStorageValue (key) {
if (!localStorageKeys.includes(key)) {
throw new Error('Invalid getLocalStorageValue key: ' + key);
}
return localStorage.getItem(key);
}
If your app heavily uses localStorage, you may want to re-write this file to better match what makes sense for your app. It's mostly just here as an example.
Benefits
The approach above forces your code into concentric rings of abstraction.
- At the lowest level is the global verb, like the
GET
function.- All GET calls in your app will go through it.
- This is the only place that needs to worry about error handling (if following the example shown)
- The next level up is the API Verbs wrapper. Like the
usersVerbs.GET
example.- All GET calls to this specific API will go through this helper
- Any special params/tokens/keys that are always required for this endpoint can live with this helper in one place
- Next up is the actual endpoint calls like
getCurrentUser
- Because this is just a simple JavaScript function, it can be imported anywhere (components, stores, route guards, plugins, other JS files, etc).
- Lastly transform functions like the
getSuperAdminLastNames
example.- Isolates data mutations in a reusable helper function.
- Transforms are fantastic when dealing with 3rd party components that want data in a specific shape different from what your API provides.
If your app only has like 2 network calls, clearly this approach will be overkill. But the apps I deal with tend to hit tons of network calls and at least a few different APIs.
It bears repeating that the usage of this approach is dead simple:
import { getCurrentUser } from '@/data/apis/users.js';
const example = async function () {
const user = await getCurrentUser();
console.log(user);
};
This results in the very positive developer experience I mentioned at the start. DX matters.
We decided to set up our network calls with a "default return no-matter-what", and to put all error handling in the global verbs. It means when using these helper functions, you don't have to worry about anything. You know they are guaranteed to always return a value that is the correct type, and that you don't need to worry about errors.
The error handling approach won't work if you need to do app-specific logic based on returned errors. But usually that is an indicator that you've designed something wrong (though not always). So feel free to tweak the examples if you need access to the error from a failed call.
In conclusion
This post is not about a library. It isn't about how to write functions. It's about how to think about the way you organize the code you write that deals with data. It presents a methodology you can apply to your projects. You are expected to take these above examples and adapt them to fit your needs.
Nothing about this approach is framework, or even language, specific.
As you try it out, be sure to comment below as to what things you changed. Your feedback will help evolve this methodology and improve it for others.
Credits:
"Concentric rings in sand" photo by Fabrizio Chiagano from Enkō-ji temple, Kyoto, Japan