In my article, "Architecting Frontend Projects To Scale", we took a look at organizing our frontend code base in a way to make scaling and succeeding as a team much easier. In this article we're going to take a small dive into the services layer of our code organization. Specifically, we will look at a simple solution for managing 3rd party APIs or our own data sources in such a way that will help us avoid some of the frustrations with managing our code base as APIs change over time.
When we first start building out features, most of us tend to dump all feature logic into a single component. The database calls, state management, and all the child components that are managed or display the data which we are presenting to the end user are located here. As a result of doing this, we begin to create a very bloated set of files that consume, manage, and present all the logic as it becomes more complex with the increase in business logic. What may have started out as simple CRUD (Create, Read, Update, Delete) actions will inevitably grow into a multitude of specialized functions and intertwined business logic. If we are not careful in our code architecture design process, we may find ourselves locked into function dependencies that are so messy that we even fear the refactoring process because we do not want to create a single bug that may have us working over the weekend to fix.
One part of this business logic mess that we can avoid is to not hard code our API calls into our components directly. Our goal is to abstract everything related to API logic into our services layer in order to make our components a little more lean and maintainable. This concept directly aligns itself with Dan Abramov's article "Presentational and Container Components" as well as creating a Model/Service layer in our frontend framework to abstract most business logic away from our reusable components.
Here is a simple example of what you may start out with:
import React, { useEffect } from 'react';
import axios from 'axios';
let API_URL_TASKS = 'https://url.com/api/v1/tasks';
export function Tasks() {
const [tasks, setTasks] = useState([]);
useEffect(() => {
_getTasks();
}, []);
function _getTasks() {
axios
.get(API_URL_TASKS)
.then((res) => {
let arr = _parseTasks(res.results.data);
setTasks(arr);
})
.catch((err) => {
_handleError(err, type);
});
}
function _parseTasks(tasks) {
return tasks.map((task) => {
// Parse task information
return task;
});
}
function _createTask(task) {
axios
.post(url, task)
.then((res) => {
_handleSuccess(res, 'post');
// etc...
})
.catch((err) => {
_handleError(err, 'post');
});
}
function _updateTask(task) {
let url = `${API_URL_TASKS}/${id}`;
axios
.patch(url, task)
.then((res) => {
_handleSuccess(res, 'patch');
// etc...
})
.catch((err) => {
_handleError(err, 'patch');
});
}
function _removeTask(id) {
let url = `${API_URL_TASKS}/${id}`;
axios
.delete(url)
.then((res) => {
_handleSuccess(res, 'delete');
// etc...
})
.catch((err) => {
_handleError(err, 'delete');
});
}
function _handleSuccess(response, type) {
// success message
// actions against state with type
}
function _handleError(error, type) {
// error message
// actions based on type
// etc...
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.name}</li>
))}
</ul>
);
}
As you can see, our component's data flow is directly related and hardcoded to one or many API endpoints that it may require. If you start to do this with many components over time, and your API requirements change from the server or 3rd party API, you have now cornered yourself into the painful process of finding all instances that need to be changed in order to avoid code and interface failure for your end user. Instead, we're going to create a few file structures in our service layer in order to make it easier to maintain changes over time.
my-app
└── src
├── components
├── views
| └── tasks
└── services
├── api
| ├── tasks
| └── utilities
├── model
| └── task
└── etc...
In the services folder, we're going to create a few utilities to make our APIs reusable and standardized for all components and team members. We'll be making use of the JavaScript axios library and JavaScript classes in this example to create our API utilities.
services
└── api
└── utilities
├── core.js
├── index.js
├── provider.js
└── response.js
We're going to focus on three main files here:
// provider.js
import axios from 'axios';
import { handleResponse, handleError } from './response';
// Define your api url from any source.
// Pulling from your .env file when on the server or from localhost when locally
const BASE_URL = 'http://127.0.0.1:3333/api/v1';
/** @param {string} resource */
const getAll = (resource) => {
return axios
.get(`${BASE_URL}/${resource}`)
.then(handleResponse)
.catch(handleError);
};
/** @param {string} resource */
/** @param {string} id */
const getSingle = (resource, id) => {
return axios
.get(`${BASE_URL}/${resource}/${id}`)
.then(handleResponse)
.catch(handleError);
};
/** @param {string} resource */
/** @param {object} model */
const post = (resource, model) => {
return axios
.post(`${BASE_URL}/${resource}`, model)
.then(handleResponse)
.catch(handleError);
};
/** @param {string} resource */
/** @param {object} model */
const put = (resource, model) => {
return axios
.put(`${BASE_URL}/${resource}`, model)
.then(handleResponse)
.catch(handleError);
};
/** @param {string} resource */
/** @param {object} model */
const patch = (resource, model) => {
return axios
.patch(`${BASE_URL}/${resource}`, model)
.then(handleResponse)
.catch(handleError);
};
/** @param {string} resource */
/** @param {string} id */
const remove = (resource, id) => {
return axios
.delete(`${BASE_URL}/${resource}`, id)
.then(handleResponse)
.catch(handleError);
};
export const apiProvider = {
getAll,
getSingle,
post,
put,
patch,
remove,
};
In this constructor class, we can define which base API resources will be consumed. We can also extend the class in each API utility to include custom endpoints unique to the API table(s) without created accidental one-off solutions littered in our code base away from this file.
// core.js
import apiProvider from './provider';
export class ApiCore {
constructor(options) {
if (options.getAll) {
this.getAll = () => {
return apiProvider.getAll(options.url);
};
}
if (options.getSingle) {
this.getSingle = (id) => {
return apiProvider.getSingle(options.url, id);
};
}
if (options.post) {
this.post = (model) => {
return apiProvider.post(options.url, model);
};
}
if (options.put) {
this.put = (model) => {
return apiProvider.put(options.url, model);
};
}
if (options.patch) {
this.patch = (model) => {
return apiProvider.patch(options.url, model);
};
}
if (options.remove) {
this.remove = (id) => {
return apiProvider.remove(options.url, id);
};
}
}
}
This is kept separate to keep our files lean and allow a clean separation for any response and error logic you may want to handle here for all API calls. Maybe you want to log an error here or create custom actions for authorization based on the response header.
// response.js
export function handleResponse(response) {
if (response.results) {
return response.results;
}
if (response.data) {
return response.data;
}
return response;
}
export function handleError(error) {
if (error.data) {
return error.data;
}
return error;
}
We can now extend our base api class to make use of all the api configurations that will be used for any api collection.
// Task API
const url = 'tasks';
const plural = 'tasks';
const single = 'task';
// plural and single may be used for message logic if needed in the ApiCore class.
const apiTasks = new ApiCore({
getAll: true,
getSingle: true,
post: true,
put: false,
patch: true,
delete: false,
url: url,
plural: plural,
single: single
});
apiTasks.massUpdate = () => {
// Add custom api call logic here
}
export apiTasks;
Now that we have our setup complete, we can import and integrate our api calls into multiple components as needed. Here is an updated Task component with our changes.
import React, { useEffect } from 'react';
import { apiTasks } from '@/services/api';
export function Tasks() {
const [tasks, setTasks] = useState([]);
useEffect(() => {
_getTasks();
}, []);
function _getTasks() {
apiTasks.getAll().then((res) => {
let arr = _parseTasks(res.results.data);
setTasks(arr);
});
}
function _parseTasks(tasks) {
return tasks.map((task) => {
// Parse task information
return task;
});
}
function _createTask(task) {
apiTasks.post(task).then((res) => {
// state logic
});
}
function _updateTask(task) {
apiTasks.patch(task).then((res) => {
// state logic
});
}
function _removeTask(id) {
apiTasks.remove(id).then((res) => {
// state logic
});
}
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.name}</li>
))}
</ul>
);
}
With a little extraction of code into reusable service utilities, our app can now manage API changes much easier. A failed API call can now be addressed in one location, it's implementation can easily tracking, and our component dependencies can be quickly updated to reflect the change in data flow and manipulation. I hope this helps you manage your API structure in such a way as to make your code not only sustainable in the long run but easily managed and understood as your code base and team grows!
Here is a link to the collection of files discussed in this article: Gist Link