What AI tools are best? Take our State of AI survey

Builder.io logo
Contact Sales
Platform
Developers
Contact Sales

Blog

Home

Resources

Blog

Forum

Github

Login

Signup

×

Visual CMS

Drag-and-drop visual editor and headless CMS for any tech stack

Theme Studio for Shopify

Build and optimize your Shopify-hosted storefront, no coding required

Resources

Blog

Get StartedLogin

‹ Back to blog

Web Development

Safe Data Fetching in Modern JavaScript

January 16, 2023

Written By Steve Sewell

Last updated April 22, 2024

Fetch - the wrong way

fetch in JavaScript is awesome.

But, you may have something like this sprinkled throughout your code:

const res = await fetch('/user')
const user = await res.json()

While nice and simple, this code has a number of issues.

You could say “oh, yeah, handle errors”, and rewrite it like this:

try {
  const res = await fetch('/user')
  const user = await res.json()
} catch (err) {
  // Handle the error
}

That is an improvement, certainly, but still has issues.

Here, we’re assuming user is in fact a user object… but that assumes that we got a 200 response.

But fetch does not throw errors for non-200 statuses, so you could have actually received a 400 (bad request), 401 (not authorized), 404 (not found), 500 (internal server error), or all kinds of other issues.

So, we could make another update:

try {
  const res = await fetch('/user')

  if (!res.ok) {
    switch (res.status) {
      case 400: /* Handle */ break
      case 401: /* Handle */ break
      case 404: /* Handle */ break
      case 500: /* Handle */ break
    }
  }

  // User *actually* is the user this time
  const user = await res.json()
} catch (err) {
  // Handle the error
}

Now, we’ve finally achieved a pretty good usage of fetch. But this can be a bit clunky to have to remember to write out every time, and you’d have to hope everyone on your team is handling each of these situations every time.

It also isn’t the most elegant in terms of control flow. In terms of readability, I personally prefer the problematic code at the beginning of this article (in some ways). It read pretty cleanly - fetch the user, parse to json, do things with user object.

But in this format we have fetch the user, handle a bunch of error cases, pare the json, handle other error cases etc. It’s a little jarring, especially when at this point we have error handling both above and below our business logic, as opposed to centralized in one place.

A more elegant solution could be to throw if the request has problems, as opposed to handling errors in multiple places:

try {
  const res = await fetch('/user')

  if (!res.ok) {
    throw new Error('Bad fetch response')
  }

  const user = await res.json()
} catch (err) {
  // Handle the error
}

But we’re left with one last problem - when it comes time to handle the error, we’ve lost a lot of useful context. We can’t actually access res in the catch block, so at the time of processing the error we don’t actually know what the status code or body of the response was.

This will make it hard to know the best action to take, as well as leave us with very uninformative logs.

An improved solution here could be to create your own custom error class, where you can forward the response details:

class ResponseError extends Error {
  constructor(message, res) {
    super(message)
    this.response = res
  }
}

try {
  const res = await fetch('/user')

  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }

  const user = await res.json()
} catch (err) {
  // Handle the error, with full access to status and body
  switch (err.response.status) {
    case 400: /* Handle */ break
    case 401: /* Handle */ break
    case 404: /* Handle */ break
    case 500: /* Handle */ break
  }
}

Now, when we preserve the status codes, we can be much smarter about the error handling.

For instance, we can alert the user on a 500 that we had an issue, and to potentially try again or contact our support.

Or if the status is 401, they are currently unauthorized and may need to log in again, etc.

I’ve got one last issue with our latest and greatest solution - it still depends on the developer to write a decent bit of boilerplate every time. Making changes project-wide, or enforcing that we always use this structure, can still be a challenge.

That’s where we can wrap fetch to handle things as we need:

class ResponseError extends Error {
  constructor(message, res) {
    this.response = res
  }
}

export async function myFetch(...options) {
  const res = await fetch(...options)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return res
}

And then we can use it as follows:

try {
  const res = await myFetch('/user')
  const user = await res.json()
} catch (err) {
  // Handle issues via error.response.*
}

In our final example, it would be good to ensure we have a unified way that we handle errors. This may include alerts to users, logging, etc.

Exploring this was fun and all, but it’s important to keep in mind that you don’t always have to create your own wrappers for things. Here are some preexisting options that are popular and may be worth using, including some that are under 1kb in size:

Axios is a very popular option for data fetching in JS, which handles several of the above scenarios for us automatically.

try {
  const { data } = await axios.get('/user')
} catch (err) {
  // Handle issues via error.response.*
}

My only critique of Axios is it is surprisingly large for a simple data fetching wrapper. So if kb size is a priority for you (which I’d argue it generally should be to keep your performance top notch), you may want to check out one of the below two options:

If you love Axios, but don’t love that it’ll add 11kb to your bundle, Redaxios is a great alternative, that uses the same API as Axios, but in less than 1kb.

import axios from 'redaxios'
// use as you would normally

One newer option, which is a very thin wrapper around Fetch much like Redaxios, is Wretch. Wretch is unique in that it largely still feels like fetch, but gives you helpful methods for handling common statuses which can chain together nicely:

const user = await wretch("/user")
  .get()
  // Handle error cases in a more human-readable way
  .notFound(error => { /* ... */ })
  .unauthorized(error => { /* ... */ })
  .error(418, error => { /* ... */ })
  .res(response => /* ... */)
  .catch(error => { /* uncaught errors */ })

Last but not least, let’s not forget that using fetch directly can have common pitfalls when sending data via a POST, PUT, or PATCH

Can you spot the bug in this code?

// 🚩 We have at least one bug here, can you spot it?
const res = await fetch('/user', {
  method: 'POST',
  body: { name: 'Steve Sewell', company: 'Builder.io' }
})

There is at least one, but likely two.

First, if we are sending JSON, the body property must be a JSON-serialized string:

const res = await fetch('/user', {
  method: 'POST',
  // ✅ We must JSON-serialize this body
  body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})

That can be easy to forget, but if we are using TypeScript this can at least be caught for us automatically.

An additional bug, which TypeScript will not catch for us, is that we are not specifying the Content-Type header here. Many backends require you to specify this, as they will not process the body properly otherwise.

const res = await fetch('/user', {
  headers: {
    // ✅ If we are sending serialized JSON, we should set the Content-Type:
    'Content-Type': 'application/json'
  },
  method: 'POST',
  body: JSON.stringify({ name: 'Steve Sewell', company: 'Builder.io' })
})

Now, we have a relatively robust and safe solution.

We could decide to add some safety for these common situations in our wrapper as well. For instance with the below code:

const isPlainObject = value => value?.constructor === Object

export async function myFetch(...options) {
  let initOptions = options[1]
  // If we specified a RequestInit for fetch
  if (initOptions?.body) {
    // If we have passed a body property and it is a plain object or array
    if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
      // Create a new options object serializing the body and ensuring we
      // have a content-type header
      initOptions = {
        ...initOptions,
        body: JSON.stringify(initOptions.body),
        headers: {
          'Content-Type': 'application/json',
          ...initOptions.headers
        }
      }
    }
  }

  const res = await fetch(...initOptions)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return res
}

And now we can just use our wrapper like so:

const res = await myFetch('/user', {
  method: 'POST',
  body: { name: 'Steve Sewell', company: 'Builder.io' }
})

Simple and safe. I like.

While it is fun and interesting to define our own abstractions, let’s make sure to point out how a couple popular open source projects also handle these situations for us automatically:

For Axios and Redaxios, code similar to our original “flawed” code with raw fetch actually works as expected:

const res = await axios.post('/user', {
  name: 'Steve Sewell', company: 'Builder.io' 
})

Similarly, with Wretch, the most basic example works as expected as well:

const res = await wretch('/user').post({ 
  name: 'Steve Sewell', company: 'Builder.io' 
})

Last, but not least, if you want to implement your own wrapper around fetch, let’s at least make sure it is type-safe with TypeScript if that is what you are using (and hopefully you are!).

Here is our final code, including type definitions:

const isPlainObject = (value: unknown) => value?.constructor === Object

class ResponseError extends Error {
  response: Response

  constructor(message: string, res: Response) {
    super(message)
    this.response = res
  }
}

export async function myFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  let initOptions = init
  // If we specified a RequestInit for fetch
  if (initOptions?.body) {
    // If we have passed a body property and it is a plain object or array
    if (Array.isArray(initOptions.body) || isPlainObject(initOptions.body)) {
      // Create a new options object serializing the body and ensuring we
      // have a content-type header
      initOptions = {
        ...initOptions,
        body: JSON.stringify(initOptions.body),
        headers: {
          "Content-Type": "application/json",
          ...initOptions.headers,
        },
      }
    }
  }

  const res = await fetch(input, initOptions)
  if (!res.ok) {
    throw new ResponseError("Bad response", res)
  }
  return res
}

When using our shiny new type-safe fetch wrapper, you will run into one last issue. In a catch block in typescript, by default error is of any type

try {
  const res = await myFetch
} catch (err) {
  // 🚩 Doh, error is of `any` type, so we missed the below typo:
  if (err.respons.status === 500) ...
}

You could say, oh! I’ll just type the error:

try {
  const res = await myFetch
} catch (err: ResponseError) {
  // 🚩 TS error 1196: Catch clause variable type annotation must be 'any' or 'unknown' if specified
}

Ugh, that’s right, we can’t type errors in TypeScript. That’s because technically you can throw anything in TypeScript, anywhere. The below is all valid JavaScript/TypeScript and could theoretically exist in any try block

throw null
throw { hello: 'world' }
throw 123
// ...

Not to mention that fetch itself could throw it’s own error that is not a ResponseError, for example for network errors such as no connection being available.

We could also accidentally have a legitimate bug in on our fetch wrapper that throws other errors like a TypeError

So a final, clean, and type-safe usage of this wrapper, would be something like:

try {
  const res = await myFetch('/user')
  const user = await res.body()
} catch (err: unknown) {
  if (err instanceof ResponseError) {
    // Nice and type-safe!
    switch (err.response.status) { ... }
  } else {
    throw new Error('An unknown error occured when fetching the user', {
      cause: err
    })
}

Here, we can check with instanceof if err is a ResponseError instance, and get full type safety within the conditional block for the error response.

And then we can also re-throw an error if any unexpected errors occured, and use the new Error cause property in JavaScript to forward the original error details for nicer debugging.

Finally, it may be nice to not always have to have a custom built switch for every possible error status for each HTTP call.

It would be nice to encapsulate our error handling into a reusable function, that we can use as a fallback after we handle any one-off cases we know we want special logic for that is unique to this call.

For instance, we may have a common way we want to alert users of a 500 with an "oops, sorry, please contact support" message, or for a 401 a "please log in again" message, as long as there is not a more specific way we want to handle this status for this particular request.

This, in practice, could for example look like:

try {
  const res = await myFetch('/user')
  const user = await res.body()
} catch (err) {
  if (err instanceof ResponseError) {
    if (err.response.status === 404) {
      // Special logic unique to this call where we want to handle this status,
      // like to say on a 404 that we seem to not have this user
      return
    }
  }
  // ⬇️ Handle anything else that we don't need special logic for, and just want
  // our default handling
  handleError(err)
  return
}

Which we could implement like:

export function handleError(err: unkown) {
  // Safe to our choice of logging service
  saveToALoggingService(err);

  if (err instanceof ResponseError) {
    switch (err.response.status) {
      case 401:
        // Prompt the user to log back in
        showUnauthorizedDialog()
        break;
      case 500: 
        // Show user a dialog to apologize that we had an error and to 
        // try again and if that doesn't work contact support
        showErrorDialog()
        break;
      default:
        // Show 
        throw new Error('Unhandled fetch response', { cause: err })
    }
  } 
  throw new Error('Unknown fetch error', { cause: err })
}

This is one place I think Wretch shines, as the above code could similarly look like:

try {
  const res = await wretch.get('/user')
    .notFound(() => { /* Special not found logic */ })
  const user = await res.body()
} catch (err) {
  // Catch anything else with our default handler
  handleError(err);
  return;
}

With Axios or Redaxios, things look similar to our original example

try {
  const { data: user } = await axios.get('/user')
} catch (err) {
  if (axios.isAxiosError(err)) {
    if (err.response.status === 404) {
      // Special not found logic
      return
    }
  }
  // Catch anything else with our default handler
  handleError(err)
  return
}

There are two last tips I like to always use for leveling up my fetch() calls:

Timeouts can be added to your fetch calls with just one line of code using AbortSignal.timeout()

Thanks to Guillermo on Twitter for sharing this.

try {
  const result = await fetch("/url", {
    signal: AbortSignal.timeout(3000),
  });
} catch (err) {
  if (err.name === 'AbortError') {
    // Request timed out
  }
}

One last thing I love to add to any fetch wrapper is the ability to pass in a type argument of the response for better type safety, so we can do this:

const person = await myFetch<Person>('/person')

person.name // ✅ string
person.foo // ❌ error

Which can be implemented like so:

export async function myFetch<Type = any>(url: string, options?: RequestInit) {
  const res = await fetch(url, options)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return (await res.json()) as Type;
}

You can also get fancy with overloads so you can automatically match types for given URLs, such as:

const person = await myFetch('/person')

person.name // ✅ string
person.foo // ❌ error

Which you can implement with overloads such as:

export async function myFetch(url: '/person', options?: RequestInit): Promise<Person>;
export async function myFetch<Type = any>(url: string, options?: RequestInit) {
  const res = await fetch(url, options)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return (await res.json()) as Type;
}

You can do this for as many things as you like, and the best part is there is no runtime cost. You can add type checking for all of your commonly known paths, even if they take parameters you can use template literal types in TypeScript for this like:

export async function myFetch(url: '/person', options?: RequestInit): Promise<Person>;
export async function myFetch(url: `/person/${string}`, options?: RequestInit): Promise<SpecificPerson>;
export async function myFetch<Type = any>(url: string, options?: RequestInit) {
  const res = await fetch(url, options)
  if (!res.ok) {
    throw new ResponseError('Bad fetch response', res)
  }
  return (await res.json()) as Type;
}

And now based on what path you are fetching, you automatically get the correct types.

And there we have it!

If not otherwise clear, I would personally recommend using an off the shelf wrapper for fetch, as they can be very small (1-2kb), and generally have more documentation, testing, and community in place, besides being already proven and verified by others as an effective solution.

But all of this said, whether you choose to manually use fetch, write your own wrapper, or use an open source wrapper - for the sake of your users and your team, please be sure to fetch your data properly :)

Hi! I'm Steve, CEO of Builder.io.

We make an easy way to convert Figma designs to code using AI, letting you go from idea to production faster than ever.

It even codes like you, matching your coding style and reusing your components. Check it out:

Introducing Visual Copilot: convert Figma designs to high quality code in a single click.

Try Visual CopilotGet a demo

Share

Twitter
LinkedIn
Facebook
Hand written text that says "A drag and drop headless CMS?"

Introducing Visual Copilot:

A new AI model to turn Figma designs to high quality code using your components.

Try Visual CopilotGet a demo
Newsletter

Like our content?

Join Our Newsletter

Continue Reading
AI9 MIN
Windsurf vs Cursor: which is the better AI code editor?
December 17, 2024
AI10 MIN
Cursor AI: 5 Advanced Features You're Not Using
December 17, 2024
Visual Development8 MIN
What is Builder's Visual Development Platform?
December 13, 2024