Explore Drafts

I thought my async code was solid – until it wasn’t

dpi-advanced-async-await-patterns-in-js

When I first started working with async/await in JavaScript, I thought I was doing everything right. Async/await gave me a synchronous look while performing asynchronous operations, and I thought that was all I needed to manage my code. However, as the complexity of my applications increased, I found myself running into some unexpected issues. My seemingly solid async code was starting to break, leading me to question if I was missing something.

In this post, I'll share my journey and how I discovered the generation token pattern, a simple and effective solution that drastically improved how I handle asynchronous operations in React. But before diving into that, let's first look at two widely used approaches to handle asynchronous tasks in React: useEffect and AbortController.

async/await – looks synchronous, but it's still asynchronous

Async/await made my code look like it was running synchronously, but I realized it wasn't always the case. Under the hood, async/await still relies on Promises and the event loop, which can introduce unexpected issues like race conditions and redundant requests.

Consider the following example, where two asynchronous fetch calls are triggered one after the other:

async function fetchData() {
  const response1 = await fetch('https://example.com');
  const data1 = await response1.json();

  const response2 = await fetch('https://example.com');
  const data2 = await response2.json();

  console.log('Data 1:', data1);
  console.log('Data 2:', data2);
}

In the above case, both requests might resolve at different times, causing potential inconsistencies in the order of the data returned. But the problem doesn't end here. Async calls are generally not cancelled when they are no longer needed, which is a real issue in scenarios like type-ahead search where multiple requests can pile up.

useEffect: Managing side effects in React

In React, useEffect is the go-to hook for performing side effects such as fetching data, subscribing to events, or updating the DOM. It runs when a component mounts and can also be triggered when dependencies change. But when you're making asynchronous calls inside useEffect, you often face issues of outdated responses or unnecessary re-renders.

Let's consider a search input that triggers an API request whenever the input changes:

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

const SearchComponent = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`/api/search?q=${query}`);
      const data = await response.json();
      setResults(data);
    };

    if (query) {
      fetchData();
    }
  }, [query]);

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search"
      />
      <ul>
        {results.map((result, index) => (
          <li key={index}>{result.name}</li>
        ))}
      </ul>
    </div>
  );
};

While this works well in simple scenarios, race conditions can occur when the user types rapidly. Since the useEffect hook runs on every change to the query state, multiple requests will be sent in quick succession, and you may end up with outdated responses, like data from a previous search being rendered after a newer one.

This is where cancellation becomes important.

AbortController – Handling request cancellation

To tackle this problem, I looked into AbortController. The AbortController API allows you to cancel ongoing fetch requests. When a request is no longer needed, you can abort it to prevent unnecessary responses from being processed.

Here's how we could implement AbortController in the above example:

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

const SearchComponent = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

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

    const fetchData = async () => {
      setLoading(true);
      try {
        const response = await fetch(`/api/search?q=${query}`, { signal });
        const data = await response.json();
        setResults(data);
      } catch (error) {
        if (error.name === 'AbortError') {
          console.log('Request was aborted');
        } else {
          console.error('Fetch error:', error);
        }
      } finally {
        setLoading(false);
      }
    };

    if (query) {
      fetchData();
    }

    return () => {
      controller.abort(); // Cleanup: abort previous request
    };
  }, [query]);

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search"
      />
      {loading && <p>Loading...</p>}
      <ul>
        {results.map((result, index) => (
          <li key={index}>{result.name}</li>
        ))}
      </ul>
    </div>
  );
};

In this implementation, the AbortController cancels the previous request whenever a new query is entered. This ensures that only the latest request is processed and that unnecessary or outdated responses are discarded.

While this solution works well, it still requires manual cleanup in useEffect to ensure that the previous request is aborted.

Introducing the Generation Token Pattern – A simpler, more robust solution

While AbortController works great, I wanted a more elegant solution that didn't require dealing with cleanup or cancelling requests manually. That's when I discovered the generation token pattern.

The generation token pattern is a simple, yet effective way to handle race conditions and outdated responses without the complexity of AbortController or the manual cleanup of useEffect. The idea is to assign a unique generation token to every request, and ignore older requests if they finish after the current one.

Here's the basic implementation:

let currentGen = 0;

async function fetchData() {
  const gen = ++currentGen; // Increment generation token

  try {
    const response = await fetch('https://example.com');
    const data = await response.json();

    if (gen === currentGen) {
      console.log('Received data:', data);
    } else {
      console.log('Old data, ignored');
    }
  } catch (error) {
    console.error('Error fetching data:', error);
  }
}

fetchData();
fetchData();

By simply using the generation token, we ensure that only the most recent request gets processed. This approach is simple and doesn't require manual cleanup, making it more maintainable in large applications.

Generation token in React: With and without useRef

Now that we have a solid pattern, let's integrate it into a React component and compare two different approaches: with and without useRef.

Option 1: Using useRef (preferred for performance)

useRef allows us to persist values across renders without triggering re-renders, which is particularly useful for tracking the generation token in async operations.

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

const SearchSuggestions = () => {
  const [suggestions, setSuggestions] = useState([]);
  const [loading, setLoading] = useState(false);
  const currentGen = useRef(0); // Use ref to track generation token

  const loadSuggestions = async (value) => {
    const gen = ++currentGen.current; // Increment generation token

    setLoading(true);
    try {
      const res = await fetch(`/api/search?q=${value}`);
      const data = await res.json();

      if (gen === currentGen.current) {
        setSuggestions(data);
      }
    } catch (error) {
      console.error('Error fetching suggestions:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <input
        type="text"
        onChange={(e) => loadSuggestions(e.target.value)}
        placeholder="Search..."
      />
      {loading && <p>Loading...</p>}
      <ul>
        {suggestions.map((suggestion, idx) => (
          <li key={idx}>{suggestion}</li>
        ))}
      </ul>
    </div>
  );
};

Option 2: Without useRef (using useState)

Alternatively, you can use useState to track the generation token. This method works well for simpler use cases, but it will trigger re-renders each time the token changes.

import React, { useState } from 'react';

const SearchSuggestions = () => {
  const [suggestions, setSuggestions] = useState([]);
  const [loading, setLoading] = useState(false);
  const [currentGen, setCurrentGen] = useState(0); // Track generation token with state

  const loadSuggestions = async (value) => {
    const gen = currentGen + 1; // Generate new token
    setCurrentGen(gen); // Update state with the new generation token

    setLoading(true);
    try {
      const res = await fetch(`/api/search?q=${value}`);
      const data = await res.json();

      if (gen === currentGen) {
        setSuggestions(data);
      }
    } catch (error) {
      console.error('Error fetching suggestions:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <input
        type="text"
        onChange={(e) => loadSuggestions(e.target.value)}
        placeholder="Search..."
      />
      {loading && <p>Loading...</p>}
      <ul>
        {suggestions.map((suggestion, idx) => (
          <li key={idx}>{suggestion}</li>
        ))}
      </ul>
    </div>
  );
};

When to use each approach

  • useRef: For better performance when dealing with frequent async operations.
  • useState: Simpler to implement and better for simpler applications where performance isn’t a major concern.

Conclusion: Choosing the best approach

After experimenting with these solutions, I found the generation token pattern to be the most effective for handling race conditions and unnecessary API calls. It’s a cleaner, more maintainable solution, especially when integrated into React with useRef.

If you’re working with cancellation scenarios, AbortController is a good method to consider. However, for many cases, especially those involving search suggestions, the generation token pattern is simple and powerful.

In this post, I've shared my experiences working with async operations in JavaScript, particularly focusing on how race conditions can be managed effectively. From using useEffect for basic async handling to leveraging AbortController for cancellation scenarios, and finally introducing the generation token pattern — I found the latter to be the most effective for ensuring cleaner, more reliable code, especially in React.

If you're building applications where managing async requests and preventing unnecessary API calls is important, consider using the generation token pattern. It's simple, scalable, and helps you avoid dealing with race conditions and other complexities that can arise in a typical async workflow.

This site is protected by reCAPTCHA and the GooglePrivacy Policy andTerms of Service apply.