How can I use a Mutex with a react hook?

Viewed 1557

I want to be sure that a block of code doesn't run concurrently. The async-Mutex library doesn't seem to be working for me though. Here's a minimal replication:

/* eslint-disable */
import React, { useState, useEffect } from "react";
var Mutex = require("async-mutex").Mutex;

const Timer = () => {
  const [isActive, setIsActive] = useState(false);
  const mutex = new Mutex();

  useEffect(() => {
    mutex.runExclusive(async function () {
      console.log("starting", isActive);
      await new Promise((resolve) => setTimeout(resolve, 1000));
      console.log("ending", isActive);
    });
  }, [isActive]);

  return (
    <div className="app">
      <button onClick={() => setIsActive((prev) => !prev)} type="button">
        {isActive ? "Active" : "Inactive"}
      </button>
    </div>
  );
};

export { Timer as default };

Codesandbox

If you click the button twice quickly, you can see that it will print

starting 
true
starting 
false
starting 
true
ending 
true
ending 
false

You can see that these are interleaved.

How can I force this to not run concurrently?

2 Answers

The problem is most likely caused because mutex is allocated on every re-render, giving you a new instance.

  1. You could move the const mutex = new Mutex(); out of your component, giving you one global version for all of your Timer components, means that Timers could block each other.

  2. You could make the mutex instance stable throughout re-renders by wrapping it in a React.useMemo hook, this would have every Timer component have it's own mutex, which means that Timers could not block each other, only itself.

Replace your declaration inside your component with:

const mutex = React.useMemo(() => new Mutex(), []);

Then add mutex to your dependency array in the useEffect:

useEffect(() => {
  // Your code
}, [isActive, mutex]);

Demo

import React, { useState, useEffect, useRef, useMemo} from "react";
var Mutex = require("async-mutex").Mutex;

const Timer = () => {
  const [isActive, setIsActive] = useState(false);
  const mutex = useRef(new Mutex());
  // Or another way, it might be a bit more performant,
  // although the Mutex class has an almost empty constructor
  // const mutex= useMemo(()=> new Mutex(), []);

  useEffect(() => {
    mutex.current.runExclusive(async function () {
      console.log("starting", isActive);
      await new Promise((resolve) => setTimeout(resolve, 1000));
      console.log("ending", isActive);
    });
  }, [isActive]);

  return (
    <div className="app">
      <button onClick={() => setIsActive((prev) => !prev)} type="button">
        {isActive ? "Active" : "Inactive"}
      </button>
    </div>
  );
};

export { Timer as default };

In case you want to cancel the previous function call and support async routines cleanup on unmount (Demo):

/* eslint-disable */
import React, { useState, useEffect } from "react";
import CPromise from "c-promise2";
import { useAsyncEffect, useAsyncCallback } from "use-async-effect2";

export default function Timer() {
  const [isActive, setIsActive] = useState(false);

  const callback = useAsyncCallback(
    function* () {
      console.log("starting", isActive);
      yield CPromise.delay(1000);
      console.log("ending", isActive);
    },
    { combine: true }
  );

  useAsyncEffect(
    function* () {
      console.log("mount");
      yield callback();
    },
    [isActive]
  );

  return (
    <div className="app">
      <button onClick={() => setIsActive((prev) => !prev)} type="button">
        {isActive ? "Active" : "Inactive"}
      </button>
    </div>
  );
}
Related