skip to content
Nikolas Barwicki - Javascript Blog Nikolas's Blog

Why do people use Axios instead of Fetch

/ 11 min read

Axios vs. Fetch

In the dynamic realm of JavaScript and front-end development, selecting the appropriate tool for HTTP requests is critical. Axios and Fetch stand out as two leading contenders, each offering distinct features and benefits. This article delves into their differences and practical applications, providing a comprehensive comparison.

Data Fetching in React Using Axios

Fetching data in React with axios is a straightforward process. axios is a promise-based HTTP client for both the browser and Node.js, often lauded for its simplicity and ease of use. Here, we’ll explore a practical example involving data retrieval from the Star Wars API.

Basic Implementation

Let’s begin with a basic implementation using the fetchData function within a useEffect hook to fetch data upon component mount. axios performs a GET request, and upon promise resolution, the state variable data is updated with the fetched information. If data exists, it’s then displayed in the UI.

export default function App() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await axios.get("https://swapi.dev/api/starship/9");
      const d = response.data;
      setData(d);
    };

    fetchData();
  }, []);

  return data ? <pre>{JSON.stringify(data)}</pre> : null;
}

However, this basic implementation lacks essential features and is prone to errors. For a more robust solution, consider using tanstack-query, an excellent library by Tanner Linsley. It simplifies many complex tasks, offering features that are challenging to build from scratch. The final version of our code above is inspired by an article from the main maintainer of this package, TkDodo.

Enhanced Implementation

The improved version addresses the shortcomings of the initial code by handling loading states, errors, and canceling fetch operations when a component unmounts.

export default function App() {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState(null);
  const [error, setError] = useState<AxiosError | null>(null);

  useEffect(() => {
    let ignore = false;

    const fetchData = async () => {
      setIsLoading(true);

      try {
        const response = await axios.get("https://swapi.dev/api/starship/9");
        const d = response.data;

        if (!ignore) {
          setData(d);
          setError(null);
        }
      } catch (error) {
        if (!ignore) {
          setError(error as AxiosError);
          setData(null);
        }
      } finally {
        if (!ignore) setIsLoading(false);
      }
    };

    fetchData();
    return () => { ignore = true; };
  }, []);

  if (isLoading) return <span>Loading data...</span>;
  if (error) return <span>An error occurred...</span>;
  return data ? <pre>{JSON.stringify(data)}</pre> : null;
}

For a deeper understanding of this implementation, exploring TkDodo’s insightful article is recommended. However, our focus here is on implementing similar functionality without external dependencies like axios.

Data Fetching in React Using Fetch

Fetching data in React applications is a common task. Understanding the subtleties of different methods can greatly enhance your development process. In this segment, we’ll delve deeper into the nuances of using fetch and compare it with axios.

Barebones Example

The simplest code to fetch data might look like this:

const response = await fetch("https://swapi.dev/api/starship/9")
const data = await response.json()

This snippet illustrates the straightforward nature of fetch. As a native web API, it requires no additional libraries and is supported by most modern browsers. This simplicity is one of fetch’s key strengths.

JSON Data Transformation with Fetch

A significant difference between axios and fetch is in handling JSON data. axios automatically transforms the response to JSON format under the hood, allowing you to use the response data directly.

const data = await response.json()

fetch, as a lower-level approach, requires explicit conversion of responses to JSON, unlike axios’s automatic handling. This requirement might seem cumbersome, but it provides a more detailed level of control over HTTP requests and responses.

Basic Error Handling

The primary distinction between fetch and axios lies in their approach to JSON data transformation and error handling. fetch requires manual conversion of the response to JSON and does not throw errors for HTTP status codes like 400 or 500. Instead, you need to check the ok status of the response, which can be seen as both a feature and a limitation, depending on your application’s needs.

By default, fetch doesn’t throw errors for non-200 statuses, so the code below won’t behave the same as axios for “400 bad request”, “404 not found”, or “500 internal server error”:

try {
    const response = await fetch("https://swapi.dev/api/starship/9")
    const data = await response.json()
} catch (error) {
  // Handle the error
}

On the response object, fetch has an ok property which is a boolean. We can write a simple if statement that throws an error for non-200 statuses like this:

try {
    const response = await fetch("https://swapi.dev/api/starship/9")

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

    const data = await response.json()
} catch (error) {
  // Handle the error
}

While this code is a start, it’s far from the error handling already implemented in axios. We’re now catching errors for all responses with non-200 statuses without even trying to process the response body.

Custom ResponseError

For more robust error handling, consider creating a custom ResponseError class. This approach offers more control and specificity when managing different HTTP status codes, ensuring a more resilient and user-friendly experience.

We can create a custom ResponseError suited to our use case:

class ResponseError extends Error {
    response: Response;

    constructor(message: string, response: Response) {
        super(message);
        this.response = response;
        this.name = 'ResponseError';
    }
}

Replace the error throwing logic in our fetch routine with this:

try {
    const response = await fetch("https://swapi.dev/api/starship/9")

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

    const data = await response.json()
} catch (error) {
    switch (error.response.status) {
      case 404: /* Handle "Not found" */ break;
      case 401: /* Handle "Unauthorized" */ break;
      case 418: /* Handle "I'm a teapot!" */ break;
      // Handle other errors
      default: /* Handle default */ break;
    }
}

Sending POST Requests Using Fetch

While axios simplifies the process of sending POST requests by automatically stringifying the request body and setting appropriate headers, fetch requires these steps to be done manually. This grants developers more control but also adds complexity.

For fetch, you need to remember three actions:

  1. Set the method to POST,
  2. Set headers (in our case) to { "Content-Type": "application/json" } (Many backends require this, as they will not process the body properly otherwise.)
  3. Manually stringify the body using JSON.stringify() (if sending JSON, the body must be a JSON-serialized string).

Let’s switch from a GET to a POST request to expand our logic:

try {
    const response = await fetch("https://swapi.dev/api/starship/9", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ "hello": "world" })
    })

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

    const data = await response.json()
} catch (error) {
    switch (error.response.status) {
      case 404: /* Handle "Not found" */ break;
      case 401: /* Handle "Unauthorized" */ break;
      case 418: /* Handle "I'm a teapot!" */ break;
      // Handle other errors
      default: /* Handle default */ break;
    }
}

Using Fetch with TypeScript

Incorporating TypeScript into your fetch requests brings an added layer of robustness through type safety. By defining interfaces and using generics, TypeScript ensures that your data fetching logic is more predictable and less prone to runtime errors. This practice enhances code maintainability and readability, especially in larger applications.

Type-Safe Fetch Responses

Implementing type-safe responses ensures that your application correctly handles the data structure returned by the API. This approach minimizes runtime errors and ensures consistency throughout your application.

export async function customFetch<TData>(url: string, options?: RequestInit): Promise<TData> {
    const defaultHeaders = {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
    };

    let fetchOptions: RequestInit = {
        method: 'GET', // Default method
        headers: defaultHeaders,
        ...options
    };

    // If there's a body and it's an object, stringify it
    if (fetchOptions.body && typeof fetchOptions.body === 'object') {
        fetchOptions.body = JSON.stringify(fetchOptions.body);
    }

    // Merge the default headers with any provided headers
    fetchOptions.headers = { ...defaultHeaders, ...(options?.headers || {}) };

    try {
        const response = await fetch(url, fetchOptions);

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

        return response.json() as Promise<TData>;
    } catch (error) {
        handleFetchError(error);
    }
}

// Usage example
interface Starship {
    // Define the properties of a Starship here
}

try {
    const data = await customFetch<Starship>("https://swapi.dev/api/starship/9")
    // ... use data ...
} catch (error) {
  // ... handle error ...
}

Typing the Rejected Value of the Promise

By typing the rejected value of the promise, you provide more precise error handling. This helps in distinguishing between different types of errors and dealing with them appropriately, enhancing the robustness of your application.

In TypeScript, by default, the error in a catch block is of any type. Because of this, our previous snippets will cause an error in our IDE:

try {
    // ...
} catch (error) {
    // 🚨 TS18046: error is of type unknown
  switch (error.response.status) {
    case 404: /* Handle "Not found" */ break
    case 401: /* Handle "Unauthorized" */ break
    case 418: /* Handle "I'm a teapot!" */ break

    // Handle other errors
    default: /* Handle default */ break;
  }
}

We can’t directly type error as ResponseError. TypeScript assumes you can’t know the type of the error, as the fetch itself could throw an error other than ResponseError. Knowing this, we can have an implementation ready for nearly all errors and handle them in a type-safe manner like this:

try {
    // ...
} catch (error) {
    if (error instanceof ResponseError) {
        // Handle ResponseError
        switch (error.response.status) {
          case 404: /* Handle "Not found" */ break;
          case 401: /* Handle "Unauthorized" */ break;
          case 418: /* Handle "I'm a teapot!" */ break;
          // Handle other errors
          default: /* Handle default */ break;
        }
    } else {
        // Handle non-ResponseError errors
        throw new Error('An unknown error occurred when fetching data', {
          cause: error
        });
    }
}

To further enhance type safety and developer experience, you might consider using the zod package to parse the output of your customFetch. This approach is similar to what TypeScript ninja Matt Pocock does in his package, zod-fetch.

Conclusion

In this comprehensive exploration of data fetching in React, we’ve dissected the functionalities and nuances of axios and fetch. Both tools come with their strengths and particularities, catering to various development needs. As we wrap up, let’s distill the essence of this discussion and consider practical applications.

axios shines with its straightforward syntax and automatic JSON data handling, making it a developer favorite for its simplicity and ease of use. On the other hand, fetch, being a native browser API, offers fine-grained control over HTTP requests, a boon for developers seeking a more hands-on approach.

However, as with all tools, understanding their limitations and how to overcome them is crucial. For instance, fetch’s lack of automatic error handling for non-200 status responses can be a stumbling block. But with the custom ResponseError class and proper error handling mechanisms, you can significantly enhance its robustness.

Let’s revisit the enhanced error handling and TypeScript integration in fetch to solidify our understanding:

class ResponseError extends Error {
    response: Response;

    constructor(message: string, response: Response) {
        super(message);
        this.response = response;
        this.name = 'ResponseError';
    }
}

function handleFetchError(error: unknown) {
    if (error instanceof ResponseError) {
        // Detailed error handling based on status code
        switch (error.response.status) {
            case 404: /* Handle "Not found" */ break;
            case 401: /* Handle "Unauthorized" */ break;
            case 418: /* Handle "I'm a teapot" */ break;
            // ... other status codes ...
            default: /* Handle other errors */ break;
        }
    } else {
        // Handle non-ResponseError errors
        throw new Error('An unknown error occurred', { cause: error });
    }
}

export async function customFetch<TData>(url: string, options?: RequestInit): Promise<TData> {
    const defaultHeaders = {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
    };

    let fetchOptions: RequestInit = {
        method: 'GET', // Default method
        headers: defaultHeaders,
        ...options
    };

    // If there's a body and it's an object, stringify it
    if (fetchOptions.body && typeof fetchOptions.body === 'object') {
        fetchOptions.body = JSON.stringify(fetchOptions.body);
    }

    // Merge the default headers with any provided headers
    fetchOptions.headers = { ...defaultHeaders, ...(options?.headers || {}) };

    try {
        const response = await fetch(url, fetchOptions);

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

        return response.json() as Promise<TData>;
    } catch (error) {
        handleFetchError(error);
    }
}

In this code, we see how TypeScript adds a layer of type safety and predictability. By defining a ResponseError class, we gain control over how errors are handled and presented. Furthermore, the customFetch function illustrates how to build a more robust and versatile fetch utility, one that can be tailored to various data types through generics.

For developers leaning towards TypeScript, integrating type safety into your data fetching strategy isn’t just about preventing errors; it’s about creating a more predictable, maintainable, and scalable codebase.

As you weigh your options between axios and fetch, consider your project’s needs, your team’s familiarity with these tools, and the kind of control or simplicity you’re aiming for. Remember, the best tool is the one that aligns with your project’s objectives and enhances your development workflow.

Lastly, for those seeking a middle ground between the simplicity of axios and the control of fetch, consider libraries like wretch. It offers a much better API and functionalities like:

  • request cancellation,
  • progress monitoring,
  • and request interception, all while maintaining a small footprint.

In conclusion, whether you choose axios, fetch, or an alternative like wretch, your focus should be on writing clear, maintainable, and robust code. Understanding the strengths and weaknesses of each tool will empower you to make informed decisions and build applications that are not only functional but also resilient and enjoyable to develop.