4

Race conditions in React and beyond. A race condition guard with TypeScript

 4 years ago
source link: https://wanago.io/2020/03/02/race-conditions-in-react-and-beyond-a-race-condition-guard-with-typescript/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Fetching resources is a bread and butter of the frontend development. When doing so, we might encounter a set of issues related to race conditions . In this article, we identify them and provide a solution.

Defining a race condition

The term “ race condition ” dates back to as far as 1954. We use it in the field of not only software but also electronics. It describes a situation when the behavior of our system is dependant on a sequence of events, but its order is uncontrollable. One of the apparent examples of race conditions is present in  multithreading when two or more threads attempt to change shared data. Therefore, they are  racing to access it.

Our code does not need to utilize multiple threads to have a race condition. A similar issue might occur with asynchronous code. Imagine the following situation:

  • The user visits the  / posts / 1  page intending to view the post number 1.
    • we start fetching it, and it takes a while
  • Meanwhile, the user changes the page to  / posts / 2
    • we begin to fetch post number 2 that loads almost immediately
  • After the above completes, the first request finishes and the content of the page displays the post number 1

Unfortunately, once we create a Promise, we can’t cancel it. There has been quite a lot of discussion about whether or not it should be implemented. There even was a proposal for it , but it has been withdrawn.

If you want to know more about promises, check out Explaining promises and callbacks while implementing a sorting algorithm

Reproducing the issue in React

The above issue is quite easy to reproduce, unfortunately.

All of the below examples use React with TypeScript.

While making this article I use an API that I’ve built with Express. If you want to know more about it, check out this TypeScript Express series

interface Post {
  id: number;
  title: string;
  body: string;
}
import React from 'react';
import usePostLoading from './usePostLoading';
 
const Post = () => {
  const { isLoading, post } = usePostLoading();
  return (
    <div>
      {
        isLoading ? 'Loading...' : post && (
          <>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </>
        )
      }
    </div>
  );
};

The above component loads the post based on the URL of the application. The whole logic happens in the usePostLoading hook.

import { useRouteMatch } from 'react-router-dom';
import { useEffect, useState } from 'react';
import Post from '../interfaces/Post';
 
function usePostLoading() {
  const { params } = useRouteMatch();
  const [post, setPostState] = useState<Post>();
  const [isLoading, setLoadingState] = useState(false);
 
  useEffect(
    () => {
      setLoadingState(true);
      fetch(`http://localhost:5000/posts/${params.id}`)
        .then((response) => {
          if (response.ok) {
            return response.json();
          }
          return Promise.reject();
        })
        .then((fetchedPost) => {
          setPostState(fetchedPost);
        })
        .finally(() => {
          setLoadingState(false);
        });
    },
    [params],
  );
 
  return {
    post,
    isLoading,
  };
}

If you want to know more about how to design custom hooks, check out JavaScript design patterns #3. The Facade pattern and applying it to React Hooks

Unfortunately, the above code is prone to race conditions. The most straightforward fix for it is to introduce an additional didCancel variable, as Dan Abramov described in his article on overreacted.io .

useEffect(
  () => {
    let didCancel = false;
    
    setLoadingState(true);
    fetch(`http://localhost:5000/posts/${params.id}`)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        return Promise.reject();
      })
      .then((fetchedPost) => {
        if (!didCancel) {
          setPostState(fetchedPost);
        }
      })
      .finally(() => {
        setLoadingState(false);
      });
    
    return (() => {
      didCancel = true;
    })
  },
  [params],
);

By returning a function in our useEffect hook, we can perform a  cleanup . React runs this function when the component unmounts, as well as after every render. Thanks to that, it prevents setting the post from a previous request.

If you want to know more about how the useEffect hook works, check out Understanding the useEffect hook in React

Creating a race condition guard

The above solution might not work for you in a more complex situation. It also gets a bit messy, because we need to remember always to check the didCancel variable. Instead, we can create a custom race condition guard. It’s a concept that I took from the article of Sebastien Lorber and built on top of it. Let’s walk through it step by step.

First, we globally keep a reference to a promise:

let lastPromise: Promise<any>;

We update it every time we start making a request.

let currentPromise = fetch(`http://localhost:5000/posts/${params.id}`);
lastPromise = currentPromise;

Then, we can use the currentPromise . Since we keep a reference to the last promise, we can check if the one that resolved matches that.

currentPromise.then((response) => {
    if (response.ok) {
      return response.json();
    }
    return Promise.reject();
  })
  .then((fetchedPost) => {
    if (lastPromise === currentPromise) {
      setPostState(fetchedPost);
    }
  })

The above logic makes sure that we don’t update the state with data from an old promise. Unfortunately, we still need to check if the promises match in every callback.

A way to resolve the above issue is to return a promise that never resolves .

currentPromise.then((response) => {
    if (response.ok) {
      return response.json();
    }
    return Promise.reject();
  })
  .then((response) => {
    if (lastPromise !== currentPromise) {
      return new Promise(() => {});
    }
    return response;
  })
  .then((fetchedPost) => {
    setPostState(fetchedPost);
  })

The above logic is still a bit messy. Let’s create a class that takes care of all of the above.

class RaceConditionGuard<PromiseResolveType = any> {
  private lastPromise?: Promise<PromiseResolveType>;
  getGuardedPromise(promise: Promise<PromiseResolveType>) {
    this.lastPromise = promise;
    return this.lastPromise.then(this.preventRaceCondition());
  }
  private preventRaceCondition() {
    const currentPromise = this.lastPromise;
    return (response: PromiseResolveType) => {
      if (this.lastPromise !== currentPromise) {
        console.log('promise cancelled');
        return new Promise(() => null) as Promise<PromiseResolveType>;
      }
      return response;
    };
  }
}

The preventRaceCondition function returns a callback. It returns a promise that never resolves if the data is too old.

Moving all of this logic to a separate class cleans up our code. The only thing left to do is to use our RaceConditionGuard class.

import { useRouteMatch } from 'react-router-dom';
import { useEffect, useState } from 'react';
import Post from '../interfaces/Post';
import RaceConditionGuard from '../utilities/RaceConditionGuard';
 
const raceConditionGuard = new RaceConditionGuard();
 
function usePostLoading() {
  const { params } = useRouteMatch();
  const [post, setPostState] = useState<Post>();
  const [isLoading, setLoadingState] = useState(false);
 
  useEffect(
    () => {
      setLoadingState(true);
      raceConditionGuard.getGuardedPromise(
        fetch(`http://localhost:5000/posts/${params.id}`)
      )
        .then((response) => {
          if (response.ok) {
            return response.json();
          }
          return Promise.reject();
        })
        .then((fetchedPost) => {
          setPostState(fetchedPost);
        })
        .finally(() => {
          setLoadingState(false);
        });
    },
    [params],
  );
 
  return {
    post,
    isLoading,
  };
}
 
export default usePostLoading;

The default value of the PromiseResolveType in our class is  any , but we can easily change that by using an interface:

const raceConditionGuard = new RaceConditionGuard<Post>();
raceConditionGuard.getGuardedPromise(
  fetch(`http://localhost:5000/posts/${params.id}`)
    .then((response) => {
      if (response.ok) {
        return response.json();
      }
      return Promise.reject();
    })
)

This logic would be even cleaner if we would create a separate function that wraps the Fetch API with an additional logic. If you want to know more, check out TypeScript Generics. Discussing naming conventions

Summary

Being aware of the issue discussed in this article is an important thing. We should choose the fix taking a few things into consideration. The first solution that we’ve seen today is strictly limited to React and might be a fitting choice in many situations. The second approach works in many different cases and is completely separate from React. You can also use it regardless if you use TypeScript, or not. Also, you might want to look into solutions like Redux-Saga and RxJS.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK