How to break out of an async for loop on button press in React

Viewed 82

I have the following component. I want to break out of the loop when the button is clicked. How do I do this in React?

I tried everything I learned so far but nothing worked out for me.

import React, { useState } from 'react';

export default function Component() {
  const [abort, setAbort] = useState(false);
  const users = [{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}];

  const insertOne = async (user) => {
    return new Promise((resolve, reject) => {
      setTimeout(() => resolve(user), 1000);
    });
  };

  const handleInsert = async () => {
    for (const user of users) {
      if (abort) break;
      const insertedUser = await insertOne(user); // pretend this is uploading user to database
      console.log(insertedUser);
    }
  };

  return (
    <div>
      <button onClick={handleInsert}>Start Inserting Users</button>
      <button onClick={() => setAbort(true)}>Abort (why this doesn't work?)</button>
    </div>
  );
}

Code on stackblitz

1 Answers

What you're trying to do looks like this in vanilla JS:

const sleep = ms => new Promise(r => setTimeout(r, ms));

let abort = false;
document
  .querySelector("button")
  .addEventListener("click", e => {
    abort = true;
  });

(async () => {
  for (const i of [...Array(100)].map((_, i) => i)) {
    if (abort) {
      break;
    }

    console.log(i);
    await sleep(1000);
  }
})();
<button>abort</button>

Now, in React, state is immutable, so from the perspective of the click handler that's running your loop, the value of abort will never change. It takes a re-render for that to happen once a state set call occurs.

Here's a reproduction of the problem:

<script type="text/babel" defer>
const sleep = ms => new Promise(r => setTimeout(r, ms));

const RepeaterWithAbort = () => {
  const [abort, setAbort] = React.useState(false);

  const start = async () => {
    for (const i of [...Array(100)].map((_, i) => i)) {
      if (abort) {
        break;
      }

      console.log(i, `abort = ${abort}`);
      await sleep(1000);
    }
  };

  return (
    <div>
      <button onClick={start}>
        Start
      </button>
      <button onClick={() =>
        console.log("abort!") || setAbort(true)
      }>
        Abort
      </button>
    </div>
  );
};

ReactDOM.render(
  <RepeaterWithAbort />, 
  document.querySelector("#root")
);
</script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<div id="root"></div>

One solution is to use a ref:

<script type="text/babel" defer>
const sleep = ms => new Promise(r => setTimeout(r, ms));

const RepeaterWithAbort = () => {
  const abortRef = React.useRef(false);

  const start = async () => {
    for (const i of [...Array(100)].map((_, i) => i)) {
      if (abortRef.current) {
        break;
      }

      console.log(i);
      await sleep(1000);
    }
  };

  return (
    <div>
      <button onClick={start}>Start</button>
      <button onClick={() => abortRef.current = true}>
        Abort
      </button>
    </div>
  );
};

ReactDOM.render(
  <RepeaterWithAbort />,
  document.querySelector("#root")
);
</script>

<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Also, there's nothing special about the for ... of loop here. This would work the same with a for ... in or C-style counter loop.

Related questions (but I think this thread is clearer):

Related