0

What's the best way to prevent any further function call after the first click, despite the asynchronous operation of setState() which complicates the task?

The mechanism does not necessarily have to be based on React states, but must nevertheless be easily reversible (e.g. by login() in case of wrong credentials).

In the examples below, refs are only used to simulate very fast clicks.

The solution to check the state immediately upon entry to the handler does not seem to be working: https://stackoverflow.com/a/49642037 (https://codesandbox.io/s/stupefied-moon-uouz2)

The solution to directly disabling button via a ref is not convenient at all in the case of a form that can be submitted from any field by pressing enter: https://stackoverflow.com/a/35316216

A solution that seems to work with Class components, but looks a bit ugly and which strangely causes a double setState under codesandbox.io (why?) regardless of the number of clicks, but not locally (https://codesandbox.io/s/ecstatic-river-wuhov):

import React, { Component } from "react";

export default class Login extends Component {
  constructor() {
    super();
    this.state = { disabled: false };
    this.ref = React.createRef();
  }

  componentDidMount() {
    console.log("click");
    this.ref.current.click();
    console.log("click");
    this.ref.current.click();
    console.log("click");
    this.ref.current.click();
  }

  onClick = () => {
    this.setState(({ disabled }) => {
      disabled || this.login();
      return { disabled: true };
    });
  };

  login = () => console.log("login");

  render() {
    return (
      <button
        ref={this.ref}
        onClick={this.onClick}
        disabled={this.state.disabled}
      >
        Login
      </button>
    );
  }
}

A slightly heavier variant but without that strange side effect (https://codesandbox.io/s/compassionate-bouman-hjtv6):

import React, { Component } from "react";

export default class Login extends Component {
  constructor() {
    super();
    this.state = { disabled: false };
    this.ref = React.createRef();
  }

  componentDidMount() {
    console.log("click");
    this.ref.current.click();
    console.log("click");
    this.ref.current.click();
    console.log("click");
    this.ref.current.click();
  }

  componentDidUpdate(prevProps, prevState) {
    const { disabled } = this.state;
    if (prevState.disabled !== disabled && disabled) {
      this.login();
    }
  }

  onClick = () => this.setState({ disabled: true });

  login = () => console.log("login");
  render() {
    return (
      <button
        ref={this.ref}
        onClick={this.onClick}
        disabled={this.state.disabled}
      >
        Login
      </button>
    );
  }
}

For functional components, this solution works but causes an eslint warning (React Hook useEffect has a missing dependency) in development mode (is this bad?) (https://codesandbox.io/s/frosty-allen-tgs42):

import React, { useRef, useEffect, useState } from "react";

export default () => {
  const [disabled, setDisabled] = useState(false);

  const ref = useRef();

  useEffect(() => {
    console.log("click");
    ref.current.click();
    console.log("click");
    ref.current.click();
    console.log("click");
    ref.current.click();
  }, []);

  useEffect(() => {
    disabled && login();
  }, [disabled]);

  const onClick = () => setDisabled(true);

  const login = () => console.log("login");

  return (
    <button ref={ref} onClick={onClick} disabled={disabled}>
      Login
    </button>
  );
};

The solution for class component that caused a double setSate under codesandbox.io seems to work without problem for functional components (pattern that I'm currently using in production)(https://codesandbox.io/s/kind-hoover-ls3t3):

import React, { useRef, useEffect, useState } from "react";

export default () => {
  const [disabled, setDisabled] = useState(false);

  const ref = useRef();

  useEffect(() => {
    console.log("click");
    ref.current.click();
    console.log("click");
    ref.current.click();
    console.log("click");
    ref.current.click();
  }, []);

  const onClick = () =>
    setDisabled(prevDisabled => {
      prevDisabled || login();
      return true;
    });

  const login = () => console.log("login");

  return (
    <button ref={ref} onClick={onClick} disabled={disabled}>
      Login
    </button>
  );
};

Since none of these solutions seem really satisfactory, any suggestions?

c-cal
  • 1
  • 1
  • That eslint warning means your dependency array is empty when it probably shouldn't be. It looks like `ref.current` might need to be in there. – ellitt Apr 22 '20 at 23:22
  • Have you looked into using a debouncer? – Joshua Beckers Apr 22 '20 at 23:30
  • @ellitt: full warning: `Line 23:8: React Hook useEffect has a missing dependency: 'login'. Either include it or remove the dependency array react-hooks/exhaustive-deps`. It seems that it's the "login" that's missing from the table, but it's worse when you add it to it: `The 'login' function makes the dependencies of useEffect Hook (at line 43) change on every render...` – c-cal Apr 22 '20 at 23:31
  • Oh I see, you have two useEffects. I was just looking at that first one. – ellitt Apr 22 '20 at 23:42
  • @JayBee: Indeed a debouncer would surely allow to solve the pb. On the other hand, this solution may not be the cleanest because we must estimate the debouncer delay compared to what we expect to observe for the setState (deactivation of the button then takes over the debouncer). They will therefore not be programmatically linked. – c-cal Apr 23 '20 at 09:15

1 Answers1

0

Create a functional function with a closure that will only run a function that is passed to it one time, like this:

function runOnce(functionToRun){
  var run = false;
  return function(){
    if(!run){
      run = true;
      functionToRun();
    }
  }
}

This is from me running this in the browser console to test:

const login = runOnce(() => console.log("login"));


var x = runOnce(function(){console.log("Hi");});
undefined
x()
VM396:1 Hi
undefined
x()
undefined

EDIT based on your question:

If you need to only run it one time and allow retrying if your function fails, have your function return a boolean (or a promise that resolves to a boolean) indicating success and set the run value to that.

If your function returns a boolean (synchronous):

if(!run){
      run = true; // Block from running twice while function is executing
      var success = functionToRun();
      if(!success) { // We were not successful
        run = false; // Allow us to run again
      }
}

If your function returns a promise:

if(!run){
      run = true; // Block from running twice while function is executing
      // However you process your function's result, this is just
      // an example
      functionToRun().then().catch(() => {run = false});          
}
mikeb
  • 10,578
  • 7
  • 62
  • 120
  • That sounds really good. How to make the mechanism as easily reversible as with state-based patterns (e.g. in case of wrong credentials in login())? – c-cal Apr 23 '20 at 08:53
  • [https://codesandbox.io/s/awesome-hoover-ncngk](https://codesandbox.io/s/awesome-hoover-ncngk) – c-cal Apr 23 '20 at 08:59