Observantly try to use AbortController in JavaScript and React

Introduction

AbortController is a JavaScript API introduced in 2017 as part of the Fetch API. It provides a simple and effective way to manage and cancel asynchronous operations, like fetch requests and event listeners, particularly when working with React components that require cleanup upon unmounting.

In this article, we’ll explore how to use AbortController for:

  • Managing and canceling fetch requests

  • Cleaning up multiple event listeners in a React component

  • Avoiding memory leaks by efficiently handling component unmounts

What is AbortController?

AbortController allows you to create an abortable signal that can be passed to asynchronous operations, such as fetch requests. When you call the .abort() method, any asynchronous tasks associated with the signal are canceled immediately.

Example

const controller = new AbortController();
const { signal } = controller;

// Using the signal in a fetch request
fetch('https://api.example.com/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(error => {
    if (error.name === 'AbortError') {
      console.log('Fetch request canceled');
    } else {
      console.error('Fetch error:', error);
    }
  });

// To cancel the fetch request
controller.abort();

Use Case 1: Cancelling a Fetch Request in React with AbortController

In React, it’s common to initiate fetch requests in useEffect to fetch data when a component mounts. With AbortController, you can cancel the request when the component unmounts, avoiding unnecessary network usage and potential memory leaks.

Here’s a React example that fetches data and cancels the request if the component unmounts before it completes:

import React, { useEffect, useState } from 'react';

function DataFetchingComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    fetch('https://jsonplaceholder.typicode.com/posts', { signal })
      .then(response => response.json())
      .then(data => setData(data))
      .catch(error => {
        if (error.name === 'AbortError') {
          console.log('Fetch request was canceled');
        } else {
          console.error('Fetch error:', error);
        }
      });

    // Cleanup function to cancel the fetch request on component unmount
    return () => controller.abort();
  }, []);

  return (
    <div>
      <h1>Data</h1>
      {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : 'Loading...'}
    </div>
  );
}

export default DataFetchingComponent;

In this example:

  • The AbortController is created and passed to the fetch request.

  • When the component unmounts, the cleanup function cancels the ongoing fetch request by calling controller.abort().

Use Case 2: Setting a Timeout for a Fetch Request with AbortController

import React, { useEffect, useState } from 'react';

function DataFetcherWithTimeout() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;
    const timeout = 5000; // Set timeout in milliseconds (e.g., 5000ms = 5 seconds)

    // Set a timeout to abort the fetch request
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    // Fetch request with abort signal
    fetch('https://jsonplaceholder.typicode.com/posts', { signal })
      .then(response => {
        if (!response.ok) throw new Error('Network response was not ok');
        return response.json();
      })
      .then(data => setData(data))
      .catch(error => {
        if (error.name === 'AbortError') {
          setError('Request timed out');
        } else {
          setError('Fetch error: ' + error.message);
        }
      })
      .finally(() => clearTimeout(timeoutId)); // Clear the timeout when fetch completes

    // Cleanup to ensure the timeout and fetch are both cleared on unmount
    return () => {
      clearTimeout(timeoutId);
      controller.abort();
    };
  }, []);

  return (
    <div>
      <h1>Data with Timeout</h1>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : 'Loading...'}
    </div>
  );
}

export default DataFetcherWithTimeout;

Explanation of the Code

  1. Set a Timeout: const timeoutId = setTimeout(() => controller.abort(), timeout); sets a timeout that will automatically call controller.abort() after the specified time (5 seconds in this example).

  2. Fetch with Abort Signal: The fetch request includes the signal from the AbortController, enabling it to listen for an abort event.

  3. Timeout Cleanup: We use .finally() to ensure clearTimeout(timeoutId) is called once the fetch completes, regardless of whether it succeeded, failed, or was aborted.

  4. Abort on Unmount: In the useEffect cleanup function, both clearTimeout(timeoutId) and controller.abort() are called. This is crucial to avoid memory leaks if the component unmounts while the fetch is still pending.

  5. Error Handling: If the fetch request is aborted, we check for the error type (error.name === 'AbortError') to set a custom error message indicating that the request timed out.

As of recent updates in the JavaScript ecosystem, there’s a proposed AbortController.timeout() utility that simplifies setting timeouts with fetch requests.

import React, { useEffect, useState } from 'react';

function DataFetcherWithTimeout() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // Set a 5-second timeout for the request
    const controller = new AbortController();
    controller.timeout = 5000; // 5000 ms = 5 seconds

    // Fetch request with abort signal
    fetch('https://jsonplaceholder.typicode.com/posts', { signal: controller.signal })
      .then(response => {
        if (!response.ok) throw new Error('Network response was not ok');
        return response.json();
      })
      .then(data => setData(data))
      .catch(error => {
        if (error.name === 'AbortError') {
          setError('Request timed out');
        } else {
          setError('Fetch error: ' + error.message);
        }
      });

    // Cleanup to abort fetch on unmount
    return () => controller.abort();
  }, []);

  return (
    <div>
      <h1>Data with Timeout</h1>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : 'Loading...'}
    </div>
  );
}

export default DataFetcherWithTimeout;

Benefits of Using AbortController for Fetch Timeouts

  • Improved User Experience: Avoids making users wait indefinitely if a server is unresponsive.

  • Resource Management: Cancels requests that are no longer needed, reducing network overhead.

  • Cleaner Code: Centralized timeout management through AbortController leads to more maintainable code.

Use Case 3: Using AbortController with WritableStream

In this example, we’ll see how you might use AbortController to cancel writing to a stream. WritableStream is often used when working with large or continuous data streams, allowing you to handle and process data as it arrives. If needed, you can abort the stream mid-operation.

import React, { useEffect } from 'react';

function StreamWriter() {
  useEffect(() => {
    const controller = new AbortController();

    // Sample writable stream
    const writableStream = new WritableStream({
      start(controller) {
        console.log("Stream started");
      },
      write(chunk, controller) {
        console.log("Writing chunk:", chunk);
      },
      close() {
        console.log("Stream closed");
      },
      abort(reason) {
        console.error("Stream aborted:", reason);
      }
    });

    // Simulate writing to the stream
    const writer = writableStream.getWriter();
    writer.write("First chunk of data");

    // Abort stream after a delay
    setTimeout(() => {
      controller.abort("Timeout exceeded while writing to stream");
    }, 3000);

    // Cleanup on component unmount
    return () => {
      writer.releaseLock();
      writableStream.abort("Component unmounted");
    };
  }, []);

  return <div>Stream Writing in Progress...</div>;
}

export default StreamWriter;

Explanation

  • Writable Stream Setup: We create a WritableStream with handlers for start, write, close, and abort.

  • Abort the Stream: After a 3-second timeout, the controller aborts the stream. This triggers the abort function within the WritableStream, logging the reason.

  • Cleanup on Unmount: The writer.releaseLock() releases the lock on the WritableStream writer, and writableStream.abort() ensures any unfinished streams are properly aborted when the component unmounts.

Use Case 4: Using the reason Property with AbortController

The reason property on AbortController provides a way to specify a reason for aborting an operation, which can be useful for debugging and logging.

import React, { useEffect, useState } from 'react';

function FetchWithAbortReason() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const reason = "Request aborted by the user"; // Custom abort reason

    fetch('https://jsonplaceholder.typicode.com/posts', { signal: controller.signal })
      .then(response => response.json())
      .then(data => setData(data))
      .catch(err => {
        if (err.name === 'AbortError') {
          setError(`Fetch aborted: ${controller.signal.reason || reason}`);
        } else {
          setError('Fetch error: ' + err.message);
        }
      });

    // Simulate an abort action after 2 seconds
    setTimeout(() => {
      controller.abort(reason);
    }, 2000);

    return () => controller.abort("Component unmounted");

  }, []);

  return (
    <div>
      <h1>Data Fetch with Abort Reason</h1>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : 'Loading...'}
    </div>
  );
}

export default FetchWithAbortReason;

Explanation

  • Setting reason: We define a custom abort reason, "Request aborted by the user", which provides context for why the request was canceled.

  • Abort with Reason: When controller.abort(reason) is called, the reason is passed along with the abort signal, which can then be logged or displayed.

Output

If the abort is triggered, the error message will display the reason as "Fetch aborted: Request aborted by the user", making it clear why the operation was stopped.

Use Case 5: Managing Multiple Event Listeners in React with AbortController

You can also use AbortController to manage multiple event listeners efficiently. This is particularly useful in scenarios where multiple listeners are added, and you want to clean them up all at once.

import React, { useEffect } from 'react';

function EventListenerComponent() {
  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    // Event handler functions
    const handleClick = () => console.log('Window clicked');
    const handleMouseMove = () => console.log('Mouse moved');

    // Add event listeners with AbortController signal
    window.addEventListener('click', handleClick, { signal });
    window.addEventListener('mousemove', handleMouseMove, { signal });

    // Cleanup function to remove event listeners on unmount
    return () => controller.abort();
  }, []);

  return (
    <div>
      <h1>Event Listener Component</h1>
      <p>Check the console and interact with the window to see the event logs.</p>
    </div>
  );
}

export default EventListenerComponent;

In this example:

  • We add two event listeners (click and mousemove) to the window.

  • By attaching the same signal to both listeners, we can cancel all listeners in one line with controller.abort() when the component unmounts.

Advantages of Using AbortController in React

  • Efficient Cleanup: Using AbortController in React’s useEffect cleanup function allows you to efficiently remove event listeners or cancel async tasks, reducing the risk of memory leaks.

  • Centralized Control: By using a single AbortController instance for multiple operations, you gain centralized control, making your code easier to manage and understand.

  • Avoiding Unwanted State Updates: If a request completes after the component has unmounted, trying to set state would lead to an error. AbortController prevents such scenarios by canceling tasks that are no longer needed.

When to Use AbortController in React

  1. API Requests: Use AbortController when fetching data that might not complete by the time a component unmounts.

  2. Event Listeners: Use it to manage and clean up multiple event listeners on the window or document efficiently.

Conclusion

AbortController is a powerful tool for managing asynchronous operations in JavaScript, especially within React applications. By using it to cancel fetch requests and clean up event listeners, you can prevent memory leaks and improve application performance. Implementing AbortController ensures that your components remain efficient, even when they frequently mount and unmount. Start incorporating it into your React projects to handle asynchronous tasks more effectively and keep your codebase clean and optimized.

0
Subscribe to my newsletter

Read articles from Nguyễn Mậu Minh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nguyễn Mậu Minh
Nguyễn Mậu Minh