Replace axios with a simple custom fetch wrapper

March 30th, 2020 — 6 min read

by K. Mitch Hodge
by K. Mitch Hodge

I remember being with Matt Zabriskie when he hatched the idea of a vanilla JavaScript version of AngularJS's $http service. It seemed like a brilliant idea and that night in his hotel room at MidwestJS, he put together the first iteration.

It was awesome because working with raw XMLHttpRequest to make HTTP requests was not very fun. His library, which he later called axios is a brilliant work and functioned both in NodeJS and the Browser which I remember him being really excited about (and I was too).

It's been almost six years now and if you're reading this chances are you've at least heard of it and very likely used it in the past or are using it now. It has an enormous and growing number of downloads on npm. And while Matt's long moved on from the project, it is still actively maintained.

Since it was released, the browser standard has evolved to add a new, promise-based API for making HTTP requests that provided a much nicer developer experience. This API is called fetch and if you haven't used it yet, you really ought to check it out. It's widely supported and easily polyfillable (my favorite is unfetch because the dog mascot is cute 🐶).

Here are a few reasons you might consider swapping axios for a simple custom wrapper around fetch:

  1. Less API to learn
  2. Smaller bundle size
  3. Reduced trouble when updating packages/managing breaking changes
  4. Immediate bug fixes/releases
  5. Conceptually simpler

I have a fetch wrapper for my bookshelf app which has served me well. Let's build it together:

function client(endpoint, customConfig) {
	const config = {
		method: 'GET',
		...customConfig,
	}

	return window
		.fetch(`${process.env.REACT_APP_API_URL}/${endpoint}`, config)
		.then((response) => response.json())
}

This client function allows me to make calls to my app's API like so:

client(`books?query=${encodeURIComponent(query)}`).then(
	(data) => {
		console.log('here are the books', data.books)
	},
	(error) => {
		console.error('oh no, an error happened', error)
	},
)

However, the built-in window.fetch API doesn't handle errors in the same way axios does. By default, window.fetch will only reject a promise if the actual request itself failed (network error), not if it returned a "Client error response". Luckily, the Response object has an ok property which we can use to reject the promise in our wrapper:

function client(endpoint, customConfig = {}) {
	const config = {
		method: 'GET',
		...customConfig,
	}

	return window
		.fetch(`${process.env.REACT_APP_API_URL}/${endpoint}`, config)
		.then(async (response) => {
			if (response.ok) {
				return await response.json()
			} else {
				const errorMessage = await response.text()
				return Promise.reject(new Error(errorMessage))
			}
		})
}

Great, now our promise chain will reject if the response is not ok.

The next thing we want to do is be able to send data to the backend. We can do this with our current API, but let's make it easier:

function client(endpoint, { body, ...customConfig } = {}) {
	const headers = { 'Content-Type': 'application/json' }
	const config = {
		method: body ? 'POST' : 'GET',
		...customConfig,
		headers: {
			...headers,
			...customConfig.headers,
		},
	}
	if (body) {
		config.body = JSON.stringify(body)
	}

	return window
		.fetch(`${process.env.REACT_APP_API_URL}/${endpoint}`, config)
		.then(async (response) => {
			if (response.ok) {
				return await response.json()
			} else {
				const errorMessage = await response.text()
				return Promise.reject(new Error(errorMessage))
			}
		})
}

Sweet, so now we can do stuff like this:

client('login', { body: { username, password } }).then(
	(data) => {
		console.log('here the logged in user data', data)
	},
	(error) => {
		console.error('oh no, login failed', error)
	},
)

Next we want to be able to make authenticated requests. There are various approaches for doing this, but here's how I do it in the bookshelf app:

const localStorageKey = '__bookshelf_token__'

function client(endpoint, { body, ...customConfig } = {}) {
	const token = window.localStorage.getItem(localStorageKey)
	const headers = { 'Content-Type': 'application/json' }
	if (token) {
		headers.Authorization = `Bearer ${token}`
	}
	const config = {
		method: body ? 'POST' : 'GET',
		...customConfig,
		headers: {
			...headers,
			...customConfig.headers,
		},
	}
	if (body) {
		config.body = JSON.stringify(body)
	}

	return window
		.fetch(`${process.env.REACT_APP_API_URL}/${endpoint}`, config)
		.then(async (response) => {
			if (response.ok) {
				return await response.json()
			} else {
				const errorMessage = await response.text()
				return Promise.reject(new Error(errorMessage))
			}
		})
}

So basically if we have a token in localStorage by that key, then we add the Authorization header (per the JWT spec) which our server can then use to determine whether the user is authorized. Very common practice there.

Another handy thing that we can do is if the response.status is 401, that means the user's token is invalid (maybe it expired or something) so we can automatically log the user out and refresh the page for them:

const localStorageKey = '__bookshelf_token__'

function client(endpoint, { body, ...customConfig } = {}) {
	const token = window.localStorage.getItem(localStorageKey)
	const headers = { 'content-type': 'application/json' }
	if (token) {
		headers.Authorization = `Bearer ${token}`
	}
	const config = {
		method: body ? 'POST' : 'GET',
		...customConfig,
		headers: {
			...headers,
			...customConfig.headers,
		},
	}
	if (body) {
		config.body = JSON.stringify(body)
	}

	return window
		.fetch(`${process.env.REACT_APP_API_URL}/${endpoint}`, config)
		.then(async (response) => {
			if (response.status === 401) {
				logout()
				window.location.assign(window.location)
				return
			}
			if (response.ok) {
				return await response.json()
			} else {
				const errorMessage = await response.text()
				return Promise.reject(new Error(errorMessage))
			}
		})
}

function logout() {
	window.localStorage.removeItem(localStorageKey)
}

Depending on your situation, maybe you'd re-route them to the login screen instead.

On top of this, the bookshelf app has a few other wrappers for making requests. Like the list-items-client.js:

import { client } from './api-client'

function create(listItemData) {
	return client('list-items', { body: listItemData })
}

function read() {
	return client('list-items')
}

function update(listItemId, updates) {
	return client(`list-items/${listItemId}`, {
		method: 'PUT',
		body: updates,
	})
}

function remove(listItemId) {
	return client(`list-items/${listItemId}`, { method: 'DELETE' })
}

export { create, read, remove, update }

Conclusion

Axios does a LOT for you and if you're happy with it then feel free to keep using it (I use it for node projects because it's just great and I haven't been motivated to investigate the alternatives that I'm sure you're dying to tell me all about right now). But for the browser, I think that you'll often be better off making your own simple wrapper around fetch that does exactly what you need it to do and no more. Anything you can think of for an axios interceptor or transform to do, you can make your wrapper do it. If you don't want it applied to all requests, then you can make a wrapper for your wrapper.

Good luck!

Kent C. Dodds
Written by Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. Kent's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Learn more about Kent

If you found this article helpful.

You will love these ones as well.