•
6 min read
Enhancing Developer Experience with Fetch

Enhancing Developer Experience with Fetch: A Clean API Service Pattern

The Fetch API is a powerful, built‑in way to make HTTP requests in the browser. It’s modern, promise‑based, and works seamlessly with JavaScript. However, using raw fetch in a real‑world application often leads to repetitive boilerplate code – setting the same headers over and over, constructing URLs manually, and managing base URLs across hundreds of endpoints. This not only clutters your components but also makes maintenance a nightmare.

In this post, I’ll show you a simple yet effective pattern that wraps fetch into a clean, reusable API service layer. With just two files – a helper module and a service module – you can dramatically improve developer experience, reduce bugs, and make your API calls declarative and consistent.

The Problem with Raw fetch

Imagine you’re building an e‑commerce frontend. You need to fetch categories, brands, slides, and products. Each call requires:

  • A base URL (e.g., https://api.example.com)
  • Authentication headers (e.g., a Bearer token)
  • The Accept and Content-Type headers
  • Query string building for parameters

Without a structured approach, your code might look like this:

// In a React component
const token = import.meta.env.VITE_API_TOKEN;
const baseURL = import.meta.env.VITE_API_BASEURL;

const getCategories = async () => {
  const response = await fetch(`${baseURL}/v1/categories`, {
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    method: 'GET',
  });
  return response.json();
};

Now multiply this by ten endpoints. Every time you need to change the token or the base URL, you have to hunt down every occurrence. Adding a new endpoint means copy‑pasting the same headers and method configuration. This quickly becomes unmaintainable.

The Solution: Centralised Helpers + Service Layer

By extracting the repetitive parts into helpers and grouping all API calls into a dedicated service, we can eliminate duplication and keep our business logic clean.

Let’s look at the two files that make this possible.

helper.ts – The Foundation

export const API_BASEURL = import.meta.env.VITE_API_BASEURL

export const buildRequest = (method: string, payload?: object) => {
  const token = import.meta.env.VITE_API_TOKEN

  return {
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    method: method,
    body: JSON.stringify(payload),
  }
}

export const buildUrl = (url: string, params?: any) => {
  const urlParams = params !== null ? '?' + new URLSearchParams(params) : ''
  return API_BASEURL + url + urlParams
}
  • API_BASEURL reads the base URL from environment variables – a single source of truth.
  • buildRequest creates a standard RequestInit object with the common headers, method, and optionally a JSON‑stringified body. The token is also injected automatically.
  • buildUrl concatenates the base URL with the endpoint and appends query parameters using URLSearchParams.

Now, every API call can reuse these helpers. No more repeating the same headers or manually building query strings.

ApiService.ts – The API Service Layer

import { buildRequest, buildUrl } from '@/lib/helper'

async function getCategories() {
  const requestOptions = buildRequest('GET')
  const url = buildUrl('/v1/categories')
  const response = await fetch(url, requestOptions)
  return await response.json()
}

async function getBrands() {
  const requestOptions = buildRequest('GET')
  const url = buildUrl('/v1/brands')
  const response = await fetch(url, requestOptions)
  return await response.json()
}

async function getSlides() {
  const requestOptions = buildRequest('GET')
  const url = buildUrl('/v1/slider')
  const response = await fetch(url, requestOptions)
  return await response.json()
}

async function getProducts() {
  const requestOptions = buildRequest('GET')
  const url = buildUrl('/v1/search', {
    category: '',
    brands: '',
    searchkey: '',
    page: 0,
  })
  const response = await fetch(url, requestOptions)
  return await response.json()
}

export const ApiService = {
  getCategories,
  getBrands,
  getSlides,
  getProducts
}

Each function:

  • Calls buildRequest with the correct HTTP method.
  • Calls buildUrl with the endpoint and any optional query parameters.
  • Executes fetch and returns the parsed JSON.

Finally, we export an object ApiService that holds all these functions. This gives us a single, clean API to import anywhere in the app.

Using the Service

Now, consuming an API in a component is as simple as:

import { ApiService } from '@/services/ApiService'

const categories = await ApiService.getCategories()
const products = await ApiService.getProducts({ page: 2 })

The component no longer cares about the base URL, headers, or how the request is built. It just calls a well‑named function and gets back the data.

Benefits for Developer Experience

1. Single Source of Truth

The base URL and authentication token are defined once in helper.ts. Changing them affects every API call instantly.

2. Less Boilerplate

Adding a new endpoint means writing just a few lines – call buildRequest, buildUrl, and fetch. No more copy‑pasting headers.

3. Consistent Error Handling (easily extensible)

You can enhance buildRequest or add a wrapper around fetch to handle errors globally. For example, you could add a try‑catch, log errors, or display toasts – all in one place.

4. Query Parameter Management

buildUrl uses URLSearchParams under the hood, which automatically handles encoding and formatting. No more manual string concatenation.

5. Better Type Safety (if you use TypeScript)

You can define response types for each function and return a typed promise. This gives you autocompletion and catches errors at compile time.

6. Testability

Since the API calls are isolated in a service, you can easily mock ApiService in unit tests without touching fetch directly.

7. Readability

The code reads like a contract: ApiService.getCategories() – you immediately know what it does, without digging into implementation details.

Taking It Further

The pattern above is a great starting point, but you can extend it even more:

  • Add request/response interceptors to handle loading states or authentication refresh.
  • Include error handling – wrap the fetch call in a try‑catch and throw a custom error with status codes.
  • Support other HTTP methods like POST, PUT, DELETE by passing a payload to buildRequest.
  • Use AbortController to cancel in‑flight requests when components unmount.

Conclusion

The Fetch API is a fantastic tool, but using it raw in a large application often leads to duplication and friction. By centralising the configuration with helpers and encapsulating each endpoint in a dedicated service, you gain a clean, maintainable, and developer‑friendly API layer.

The two files – helper.ts and ApiService.ts – show that with just a little bit of structure, you can turn repetitive fetch calls into a delightful experience. Your components become simpler, your team works faster, and your codebase stays resilient to change.

Give this pattern a try on your next project – your future self (and your teammates) will thank you.