How do I submit form data after another hook is set?

Viewed 233

When a user submits a form, I have to show a modal asking them to connect if they haven't yet. On success, the modal sets the user data in a hook, and then I continue with the submission flow.

So there are 2 conditions to submit:

  1. User submits form (clicks button)
  2. User data is set

I solved it reactively, using an effect:

useEffect(() => {
  async function nestedAsync() {
    if (userData && pendingSubmitIntent) {
      pendingSubmitIntent(false);
      await submit(
        formData, // simplified - this is actually several hooks
        userData
      );
    }
  }
  nestedAsync();
}, [pendingSubmitIntent, userData]);

And the submit click handler:

setPendingSubmitIntent(true);
if (!userData) {
  setShowConnectModal(true);
}

The modal is in the component:

{setShowConnectModal && (
  <ConnectModal
    setUserData={setUserData}
  />
)}

This actually works, but I'm getting a warning that I'm not declaring formData in the dependencies array. I can't do this, because otherwise the effect will be called when editing the inputs and that's not correct. The effect has to be called only when submitting.

And this warning makes me think that there's something fundamentally wrong with this approach. A state machine comes to mind, but I feel that it should be simpler. Any ideas?

2 Answers

While it may be tempting to disable the linter warning for your dependencies, I would suggest not doing that and instead look at alternatives. By disabling the linter, any future updates to your useEffect callback can cause you to miss dependencies that you might actually need to include, leading to bugs with stale values. Below are some alternatives to consider.

Removing the useEffect()

I would reconsider the need for the useEffect() hook in the first place. From the user's perspective, their form data should be saved once they submit the form, or once they click "connect" on the subsequent modal. Both of these actions are user-triggered interactions, and so the saving logic should sit within these interactions' event handlers, and not within an effect. At the moment, your effect serves the purpose of sharing shared logic between your event handlers, which is an unnecessary need for an effect as it can be achieved by creating a shared function. You can remove the effect by putting the shared logic into its own function:

function saveData(userData) {
  submit(formData, userData); // note, you don't need to `await` something if you don't need the function to wait for it to complete
}

This shared function can now be used by both of your event handlers - the one that handles the submission of the form itself, and the other that handles the connection and population of your user data from within ConnectModal. By using this function, you can "continue" your submission flow from both the modal connect event and the original form submission event. This would mean updating your form submission handler to use this shared function like so:

// Submit click handler
if(userData) {
  saveData(userData); 
} else {
  setShowConnectModal(true);
}

It also means updating your ConnectModal event handler that sets your userData to call the saveData function. This can be done by passing through a new prop to ConnectModal, eg:

<ConnectModal
  onConnect={saveData}
  setUserData={setUserData}
/>

Within your modal component, you can then call onConnect(...) with your userData that you call setUserData(...) with (note, as you haven't shown how userData is actually set, I assume you have an event handler in ConnectModal that controls this, the below function call to onConnect() would sit in there also):

// Connect click handler within ConnectModal
onConnect(newUserData);

Giving formData a "stable identity"

Rather than storing formData as a state value, you can look at giving it a stable identity with the useRef() hook. A stable identity means that React will always returns the same ref object on every rerender. When an object has this property, then you don't need to include it in your dependency array. Moreover, including it in the dependency array doesn't hurt either as the object isn't changing, so it won't cause the effect to execute. If formData doesn't need to influence your UI, then you can swap out its state value with a ref, allowing you to omit it as a dependency:

const formDataRef = useRef(...);
// When you need to set your formData you would use `formDataRef.current = ...`

useEffect(() => {
  if (userData && pendingSubmitIntent) {
    pendingSubmitIntent(false);
    submit(formDataRef.current, userData); // use the ref's `.current` property which holds your formData
  }
}, [pendingSubmitIntent, userData]); // no need to specify the ref as it's stable accross renders

Using the useEvent() hook

While it most likely isn't needed for your case as you can remove the effect (or use useRef()), there might be legitimate cases where you need an effect to run on a change of a particular value, but the dependency values that the linter is asking you to add will cause your effect to run too often. In such a case, you can consider using the useEvent() hook (it's currently experimental, but you can add a shim/polyfill for it if needed). For example, you could do:

const onSubmit = useEvent((userData) => {
  submit(formData, userData);
});

useEffect(() => {
  if (userData && pendingSubmitIntent) {
    pendingSubmitIntent(false);
    onSubmit(userData);
  }
}, [pendingSubmitIntent, userData]);

Now the need for specifying formData within your effect is no longer needed as it's no longer used within the effect callback. The function provided to useEvent() also has access to the "latest" value of formData from the surrounding scope. Moreover, the onSubmit function returned by the useEvent() call is always the same, so it's stable. This means that it doesn't need to be provided as a dependency to the useEffect() hook call as you don't risk referring to the wrong onSubmit function. You can find more info about useEvent() here.

There are some great articles on React’s new beta documentation site relating to the above suggestions and how to best tackle your issue in different scenarios:

If the user already connected you can continue with the submission in the submitClickHandler.

const submitClickHandler = useCallback(()=>{
 if(!userData) {
  setShowConnectModal(true)
  return
 }
 (async()=>{
  if(userData && formData){
     await submit(userData && formData)
    }
  })()


},[userData,formData])

Otherwise in the ConnectModal you will submit the form

First pass the submit and 'formData' to ConnectModal

{showConnectModal && (
 <ConnectModal
    setUserData={setUserData}
    submit={submit}
    formData={formData}
/>
)}

Then set the userData and submit the form in a useEffect hook

useEffect(()=>{

 setUserData(()=>userData)
 
 (async()=>{
   if(userData && formData){
     await submit(userData && formData)
   }
 })()


 },[userData,formData])

finally close the modal

Related