1

I'm new to React and I want to implement a form where the user is given N inputs and is asked to copy/paste some contents in it (he can also click on a + or - button to add or remove some inputs but let's keep it simple for now).

The idea is that, for each input, the user will copy/paste some data and, on each input an onChange listener is attached so that each time an input is changed, it will trigger a call to an API or backend service to validate the data. Once the data is validated, it will return whether or not the user was right (by changing the background color of the input for example). Here is a picture:

enter image description here

The problem is that, let say the user copies/pastes gorilla in the first input and the async call takes 10s, but then 2s after it copied/pasted gorilla in the first input, he copies/pastes spaceship in the second input (and this async call takes 4s to return let's say). What happens with my current version is that, since the 2nd call finished before the first one, isValid is updated for the second input while it is not for the first input.

This is due to the fact that I'm using useEffect and the clean function. I have also tried to use useState instead but each time I ran into a race condition...

Notes:

  • If the user entered gorilla in the first input and 1s after he modifies the same input and enters giraffe. If the first async call did not finish yet, I prefer to kill it and only retrieve the validation for the latest call made for that same input (this is why I used a clean function in my useEffect)
  • Even if the problem of stopping the validation of other inputs is solved, I think there will always be a race condition as in my useEffect I am modifying my useState associated to each of my input so I'm not sure how to go about it.

Here is tiny sample to replicate my current behavior:

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

export default function MyComponent(props) {
  let delay = 10000; // ms
  const [data, setData] = useState(
    Array(5).fill({
      isValid: 0,
      url: null
    })
  );
  const [inputIdx, setInputIdx] = useState(-1);
  const updateData = (index, datum) => {
    const updatedData = data;
    updatedData[index] = datum;
    setData([...updatedData]);
    setInputIdx(index);
    delay /= 2;
  };
  useEffect(() => {
    let canceled = true;

    new Promise((resolve) => {
      setTimeout(() => resolve("done"), delay);
    }).then((e) => {
      if (canceled) {
        const updatedData = data;
        updatedData[inputIdx].isValid = true ^ updatedData[inputIdx].isValid;
        setData([...updatedData]);
      }
    });
    return () => {
      canceled = false;
    };
  }, [inputIdx]);
  const processData = data.map((datum, index) => {
    return (
      <input
        type="text"
        onChange={e =>
          updateData(index, {
            url: e.target.value === "" ? null : e.target.value,
            isValid: data[index].isValid,
          })
        }
        style={{display:"block"}}
      />
    );
  });
  return (
    <div>
      <div>{processData}</div>
      <div>{JSON.stringify({data})}</div>
    </div>
  );
}

Here is a CodeSandBox

If you enter "a" in the first input and just after "b" in the second input and wait for a few seconds, you'll see that isValid becomes 1 for the 2nd input and not for the 1st input (Here I toggle isValid so if it was 0 before it will be 1 after, and if it was 1 it will be 0 after the call). Also, here, to imitate a call to the backend I used setTimeout with 10s for the first call and 5s for the second call and 5/2=2.5s for the third call, ....

Do you have any idea on what I can do to solve my problem? I guess I should change the architecture itself but as I'm new to React and did not find any similar problems, I'm asking you for help.

Thank you!

3
  • There's no race condition here, you've only created state to manage/handle 1 concurrent input change/request/validation at-a-time. Interacting with another input throws away any currently processing input change/validation. Commented Feb 25, 2022 at 23:58
  • Yes. Sorry if it wasn't clear enough. But if I drop the canceled = false, do you agree I will bump into a race condition as different API calls might call setData() at the same time (or is it not possible?). Also I want to keep this behavior of throwing away any other currently processing API calls (but only if these API calls were triggered from the same input) Commented Feb 26, 2022 at 9:59
  • You should track per-input all the requests being made. If these are network requests then I suggest using cancel tokens to cancel any in-flight requests while making the new requests. Commented Feb 28, 2022 at 21:31

3 Answers 3

1

You should track per-input all the requests being made. I suggest refactoring/abstracting the asynchronous request to be considered "state" of the input that is changing. I handles the onChange event, makes the asynchronous request, and runs a passed onChange handler from the parent when the "state" is updated.

If these are network requests then I would suggest also using cancel tokens to cancel any in-flight requests while making the new requests.

Example:

const CustomInput = ({ delay = 10000, index, onChange, value }) => {
  const [internalValue, setInternalValue] = useState(value);

  const changeHandler = (e) => {
    setInternalValue((value) => ({
      ...value,
      url: e.target.value
    }));
  };

  useEffect(() => {
    onChange(index, internalValue);
  }, [index, internalValue, onChange]);

  useEffect(() => {
    let timerId = null;

    if (internalValue.url) {
      console.log("Start", internalValue.url);
      setInternalValue((value) => ({
        ...value,
        validating: true
      }));

      new Promise((resolve) => {
        timerId = setTimeout(resolve, delay, { isValid: Math.random() < 0.5 });
      })
        .then((response) => {
          console.log("Complete", internalValue.url);
          setInternalValue((value) => ({
            ...value,
            isValid: true ^ response.isValid
          }));
        })
        .finally(() => {
          setInternalValue((value) => ({
            ...value,
            validating: false
          }));
        });

      return () => {
        console.log("Cancel", internalValue.url);
        clearTimeout(timerId);
        setInternalValue((value) => ({
          ...value,
          validating: false
        }));
      };
    }
  }, [delay, internalValue.url]);

  return (
    <input
      type="text"
      onChange={changeHandler}
      style={{ display: "block" }}
    />
  );
};

Parent:

function MyComponent(props) {
  const [data, setData] = useState(
    Array(5).fill({
      isValid: 0,
      url: null,
      validating: false
    })
  );

  const updateData = useCallback((index, datum) => {
    setData((data) => data.map((el, i) => (i === index ? datum : el)));
  }, []);

  return (
    <div>
      {data.map((datum, index) => (
        <div key={index}>
          <CustomInput index={index} onChange={updateData} value={datum} />
          <span>{JSON.stringify(datum)}</span>
        </div>
      ))}
    </div>
  );
}

enter image description here

Edit react-js-multi-inputs-with-asynchronous-data-validation-and-race-condition

Sign up to request clarification or add additional context in comments.

2 Comments

Thanks for your proposition. I was able to come-up with a proposition where instead of passing the datum like you I was passing the data to the child element (and only use one useEffect instead of 2) bc I had some problems with an additional feat. I will test everything this week-end (your code with my additional feat). I make your answer the solution and I'll post mine too for another solution this we
@priseJack Sure. The second useEffect hook in my solution only exists to update the centralized state in the parent component. It's unnecessary if you are just wanting to push the "state" down to children components to maintain. I look forward to seeing your solution. Cheers.
0

I don't think you need useEffect for this. You can just listen to the paste event on your inputs, run your API call, and respond accordingly. I included a minimal verifiable example. It is a proof of concept but you can change the boundaries and responsibilities of the components in your real app. Run the code and paste each word into input to see the state changes reflected in the output.

function App() {
  const [state, setState] = React.useState({
    "gorilla": "pending",
    "broccoli": "pending",
    "cage": "pending",
    "motorcycle": "pending"
  })
  const expect = expected => actual =>
    setState(s => ({...s, [expected]: expected == actual }))
  return <div>
    <p>gorilla spaceship cage bike</p>
    <ValidateInput valid={state.gorilla} validate={expect("gorilla")} />
    <ValidateInput valid={state.broccoli} validate={expect("broccoli")} />
    <ValidateInput valid={state.cage} validate={expect("cage")} />
    <ValidateInput valid={state.motorcycle} validate={expect("motorcycle")} />
    <pre>{JSON.stringify(state)}</pre>
  </div>
}

function ValidateInput({ valid, validate }) {
  function onPaste(event) {
    const pasted = event.clipboardData.getData("text/plain")
    someApiCall(pasted).then(validate).catch(console.error)
  }
  return <input className={String(valid)} onPaste={onPaste} />
}

function someApiCall(v) {
  return new Promise(r => setTimeout(r, Math.random() * 6000, v))
}

ReactDOM.render(<App/>, document.querySelector("#app"))
input { display: block; outline: 0; }
input.true { border-color: green; }
input.false { border-color: red; }
pre { padding: 0.5rem; background-color: #ffc; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.14.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.14.0/umd/react-dom.production.min.js"></script>
<div id="app"></div>

4 Comments

Thank you for your answer. However, wouldn't it be preferable to use useEffect? Here, when I test the code snippet and I paste "gorilla" in the first input and then quickly after I paste "broccoli", sometimes the output will be `{"gorilla": true}, because indeed, the call to the api for "gorilla" > call for "broccoli" + time spend to copy/paste "broccoli" after "gorilla" was pasted.
When we wait for the "gorilla" response, pasting in another field has no impact on the result of "gorilla". Each ValidateInput operates on a different "slot" of the state, using the validate function. The fields can be pasted in any order, the results can arrive in any order, and the state effects will always be the same. useEffect as a preferable solution would depend on your actual app semantics: where state is stored, which component makes the validation call, etc. In the provided code I cannot see how useEffect would make any improvement.
I was refering to the same ValidateInput. The user pastes gorilla and quickly after pastes any other words to the same ValidateInput and we might end up with {"gorilla": true} while the latest pasted word for that input was something else. Does not look right to me that is why I mentioned maybe using a useEffect with a cleaning fct will be better? Don't you agree?
I still wouldn't use useEffect for that. I think a better solution for that is to disable the input while it is waiting for a response. That way only inputs that are waiting for user input can be pasted into.
0

The author of the this post here, As @Drew Reese mentioned, to solve my problem, I need to trigger the requests per per-input. I've already accepted the answer of that Author as it solved my problem, but here is my own solution and also a little bit more explanation (don't hesitate to correct me in the comments and I'll amend my answer if anything I say is wrong as I've just started React 1 week ago).

First of, let's explain the title: asynchronous data validation and race condition.

To be concise, as JS is a single-threaded language, we cannot really have a race condition. However, using async functions we can have what looks like a race condition because the same data can be changed by 2 different calls in an asynchronous way.

Basically, here, what I consider could be interpreted as a race condition is if the user type a letter in one input (that will trigger async call 1a) and then quickly after he types another letter in the same input (that will trigger async call 1b), what I don't want after both the async calls are done is to have the result of async call 1a instead of async call 1b (the latest request made).

To avoid this situation, we can just handle per-input request and use a flag (here canceled = True) to prevent this kind of situation. Why it works? Because JS is single-threaded so before making the async call we can simply set a flag variable.

However, useEffect is triggered at each render (and a render, among other things, is triggered each time we mutate a variable defined by useState). To avoid entering the useEffect call for each input, we can specify a parameter (here: inputUrl) so that only the useEffect associated to the ValidInput element for which the url was changed is triggered.

Another place where this kind of race condition problem can occur is in setData, because setData is asynchronous in React. To prevent that we need to use the lambda operator together with setData, this ensures that, we always use the previous state as input (here: updateInputs((previousInputs) => {... where updateInputs points to the setData function defined by the useState)

If we put everything together, we have something that works quite well.

Notes:

  • Here, I have added if (allInputs.map((e) => e.url).every((e) => e === null)) in the code, it is just for the template version to avoid calling useEffect for all the inputs at the first render
  • I could have not used useEffect but using useEffect make it much easier to avoid the kind of race condition problem just by using a flag (called a clean-up function here)
  • I have added some code to add an input and delete any input as well.
  • I'm not a pro so maybe there are other things that can be done better

the code:

import "./styles.css";
import React, { useEffect, useState } from "react";

export default function MyComponent(props) {
  const [data, setData] = useState(
    Array.from({ length: 5 }, () => {
      return { isValid: 0, url: null };
    })
  );
  const addEntry = () => {
    setData([
      ...data,
      {
        isValid: 0,
        url: null
      }
    ]);
  };
  const processData = data.map((datum, index) => {
    return (
      <ValidInput
        key={index}
        allInputs={data}
        updateInputs={setData}
        index={index}
      />
    );
  });
  return (
    <div>
      <div>{processData}</div>
      <div>
        <button onClick={addEntry}>ADD</button>
      </div>
      <div>{JSON.stringify({ data })}</div>
    </div>
  );
}

const ValidInput = ({ allInputs, updateInputs, index }) => {
  const inputUrl = allInputs[index].url;
  let delay = 10000;
  useEffect(() => {
    // Needed only for this template because the promise here
    // is mocked by a setTimeout that will be trigger on the first render
    if (allInputs.map((e) => e.url).every((e) => e === null)) {
      return;
    }
    let canceled = true;
    new Promise((resolve) => {
      delay /= 2;
      setTimeout(() => resolve("done"), delay);
    }).then((e) => {
      if (canceled) {
        updateInputs((previousInputs) => {
          const updatedInputs = [...previousInputs];
          updatedInputs[index] = {
            ...updatedInputs[index],
            isValid: updatedInputs[index].isValid ^ 1
          };
          return updatedInputs;
        });
      }
    });
    return () => {
      canceled = false;
    };
  }, [inputUrl]);
  const onChange = (e) => {
    updateInputs((previousInputs) => {
      const updatedInputs = [...previousInputs];
      updatedInputs[index] = {
        ...updatedInputs[index],
        url: e.target.value
      };
      return updatedInputs;
    });
  };
  const removeEntry = (index) => {
    updateInputs((previousInputs) => {
      return previousInputs.filter((_, idx) => idx !== index);
    });
  };
  return (
    <div key={index}>
      <input
        type="text"
        onChange={(e) => onChange(e)}
        value={inputUrl != null ? inputUrl : ""}
      />
      <button onClick={() => removeEntry(index)}>DEL</button>
    </div>
  );
};

And here is the sandbox.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.