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

栏目: IT技术 · 发布时间: 5年前

内容简介:Fetching resources is a bread and butter of the frontend development. When doing so, we might encounter a set of issues related toThe term “Our code does not need to utilize multiple threads to have a race condition. A similar issue might occur with asynch

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.


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

JavaScript入门经典

JavaScript入门经典

Paul Wilton、Jeremy McPeak / 施宏斌 / 清华大学出版社 / 2009-2 / 98.00元

《Java Script入门经典(第3版)》首先介绍了J avaScript的基本语法,并介绍了如何发挥JavaScript中对象的威力。《Java Script入门经典(第3版)》还介绍了如何操纵最新版本浏览器所提供的BOM对象。在《Java Script入门经典(第3版)》的高级主题中,将介绍如何使用cookie,以及如何应用DHTML技术使Web页面焕发动感和活力。另外,《Java Scri......一起来看看 《JavaScript入门经典》 这本书的介绍吧!

JS 压缩/解压工具
JS 压缩/解压工具

在线压缩/解压 JS 代码

CSS 压缩/解压工具
CSS 压缩/解压工具

在线压缩/解压 CSS 代码

在线进制转换器
在线进制转换器

各进制数互转换器