简体   繁体   中英

How to wait for multiple state updates in multiple hooks?

Example

In my scenario I have a sidebar with filters.. each filter is created by a hook:

const filters = {
  customerNoFilter: useFilterForMultiCreatable(),
  dateOfOrderFilter: useFilterForDate(),
  requestedDevliveryDateFilter: useFilterForDate(),
  deliveryCountryFilter: useFilterForCodeStable()
  //.... these custom hooks are reused for like 10 more filters 
}

Among other things the custom hooks return currently selected values, a reset() and handlers like onChange , onRemove . (So it's not just a simple useState hidden behind the custom hooks, just keep that in mind)

Basically the reset() functions looks like this:

I also implemented a function to clear all filters which is calling the reset() function for each filter:

const clearFilters = () => {
    const filterValues = Object.values(filters);
    for (const filter of filterValues) {
      filter.reset();
    }
  };

The reset() function is triggering a state update (which is of course async) in each filter to reset all the selected filters.

// setSelected is the setter comming from the return value of a useState statement
const reset = () => setSelected(initialSelected);

Right after the resetting I want to do stuff with the reseted/updated values and NOT with the values before the state update , eg calling API with reseted filters:

clearFilters();
callAPI();

In this case the API is called with the old values (before the update in the reset() ) So how can i wait for all filters to finish there state updated? Is my code just badly structured? Am i overseeing something?

For single state updates I could simply use useEffect but this would be really cumbersome when waiting for multiple state updates..

Please don't take the example to serious as I face this issue quite often in quite different scenarios..

So I came up with a solution by implementing a custom hook named useStateWithPromise :

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

export const useStateWithPromise = <T>(initialState: T):
  [T, (stateAction: SetStateAction<T>) => Promise<T>] => {
  const [state, setState] = useState(initialState);
  const readyPromiseResolverRef = useRef<((currentState: T) => void) | null>(
    null
  );

  useEffect(() => {
    if (readyPromiseResolverRef.current) {
      readyPromiseResolverRef.current(state);
      readyPromiseResolverRef.current = null;
    }

    /** 
     *  The ref dependency here is mandatory! Why?
     *  Because the useEffect would never be called if the new state value
     *  would be the same as the current one, thus the promise would never be resolved
     */
  }, [readyPromiseResolverRef.current, state]);

  const handleSetState = (stateAction: SetStateAction<T>) => {
    setState(stateAction);
    return new Promise(resolve => {
      readyPromiseResolverRef.current = resolve;
    }) as Promise<T>;
  };

  return [state, handleSetState];
};

This hook will allow to await state updates:

 const [selected, setSelected] = useStateWithPromise<MyFilterType>();

 // setSelected will now return a promise
 const reset = () => setSelected(undefined);
const clearFilters = () => {
    const promises = Object.values(filters).map(
      filter => filter.reset()
    );

    return Promise.all(promises);
};

await clearFilters();
callAPI();

Yey, I can wait on state updates! Unfortunatly that's not all if callAPI() is relying on updated state values..

const [filtersToApply, setFiltersToApply] = useState(/* ... */);

//...

const callAPI = ()  => {
   // filtersToApply will still contain old state here, although clearFilters() was "awaited"
   endpoint.getItems(filtersToApply); 
}

This happens because the executed callAPI function after await clearFilters(); is is not rerendered thus it points to old state. But there is a trick which requires an additional useRef to force rerender after filters were cleared:

 useEffect(() => {
    if (filtersCleared) {
      callAPI();
      setFiltersCleared(false);
    }
    // eslint-disable-next-line
  }, [filtersCleared]);

//...

const handleClearFiltersClick = async () => {
    await orderFiltersContext.clearFilters();
    setFiltersCleared(true);
};

This will ensure that callAPI was rerendered before it is executed.

That's it. IMHO a bit messy but it works.


If you want to read a bit more about this topic, feel free to checkout my blog post .

There are definitely other ways to achieve this but this one came to my mind. You can have a state variable called hasReset and set it to true after clearFilters() . And can have an effect that listens to hasReset , and if it is true runs callApi() and sets hasReset to false .

Example:

const [hasReset, setHasReset] = useState(false);

useEffect(() => {
  if (hasReset) {
    callApi();
    setHasReset(false);
  }
}, [hasReset]);

const clearFilters = () => {
  const filterValues = Object.values(filters);
  for (const filter of filterValues) {
    filter.reset();
  }
  setHasReset(true);
};

To answer the question above, If you are using useState inside your custom hooks, you don't need to worry about the async part. The UseState hook is only to declare a variable of each render, it's different from this.setState inside the class component and it will also run by order.

Here is the link of a related article https://overreacted.io/why-do-hooks-rely-on-call-order/

BTW, I actually also feel confused that why we need so many filter hooks, which seems like having similar logic. why not pass the options to the one filter hook and run the logic inside. I also think to have a reducer managing the state is a better approach, you don't want those hooks having their own state and value returns

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM