ysfaran
ysfaran

Reputation: 6952

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, e.g. 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..

Upvotes: 4

Views: 2452

Answers (1)

ysfaran
ysfaran

Reputation: 6952

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.

Upvotes: 2

Related Questions