DEV Community

Yusuf Aran
Yusuf Aran

Posted on • Edited on • Originally published at ysfaran.github.io

useStateWithPromise: a custom hook to await state updates of useState

Alt Text

Originally posted on my blog.

The Problem

The general problem is that we want to wait for a state update an then do something afterwards. Unfortunatly we can not write sequential code, because every state update is asynchronous.
In "old" react we could simply pass a callback. As an example we will look at a class component, that contains filters for an article list view:

class FilterSidebar extends React.Component {
  constructor(){
    this.state = {
      filters: {},
      articles: []
    }
  }

  fetchArticles = async () => {
    const fetchedArticles = await API.getArticles(this.state.filters);
    this.setState({articles: fetchedArticles})
  }

  reset = () => {
    this.setState({filters: {}}, this.fetchArticles);
  }

  setColorFilter = (color) =>  {
    this.setState(state => ({filters: {...state.filters, color}));
  }

  // more filters & render ...
}
Enter fullscreen mode Exit fullscreen mode
  • fetchArticles: fetch articles from an API service based on the filters in the state.
  • reset: clear all filters and then fetch articles, by passing fetchArticles as callback to setState. This will guarantee that the state of filters is cleared before calling fetchArticles
  • setColorFilter: sets filter for articles to have a specific color (just an example to help your imagination!)

Using functional components this would look a bit different:

const FiltersSidebar = () => {
  const [articles, setArticles] = useState([]);
  const [filters, setFilters] = useState({});

  const fetchArticles = async () => {
    const fetchedArticles = await API.getArticles(filters);
    setArticles(fetchedArticles)
  }

  const reset = () => {
    setFilters({});

    // uuhh, ouhh .. fetchArticles will use old state of "filters"
    fetchArticles();
  }

  const setColorFilter = (color) =>  {
   setFilters(currentFilters => ({...currentFilters, color}));
  }

  // more filters & return ..
}
Enter fullscreen mode Exit fullscreen mode

The problem here is that the setter, which is returned by useState (here setFilters), doesn't allow us to pass a callback function as second argument. But in this case we can use useEffect and useRef to handle the problem:

const FiltersSidebar = () => {
  const [articles, setArticles] = useState([]);
  const [filters, setFilters] = useState({});
  const resettingRef = useRef(false);

  const fetchArticles = async () => {
    const fetchedArticles = await API.getArticles(filters);
    setArticles(fetchedArticles)
  }

  const reset = () => {
    resettingRef.current = true;
    setFilters({});
  }

  useEffect(() => {
    if(resettingRef.current){
      resettingRef.current = false;
      fetchArticles();
    }
  },[filters])

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Okay, that looks a bit ugly but at least it works..
But what happens if the filter logic gets much more complicated and we want to extract logic for filters in custom hooks:

const useStringFilter = (initialValue = "") => {
  const [value, setValue] = useState(initialValue);

  // maybe more complex stuff here

  const reset = () => {
    setValue(initialValue)
  }

  return {
    value,
    setValue,
    reset
  }
}

// and filters for other types like useDateFilter etc..
Enter fullscreen mode Exit fullscreen mode

Then our component could look like this:

const FiltersSidebar = () => {
  const [articles, setArticles] = useState([]);

  const colorFilter = useStringFilter();
  const nameFilter = useStringFilter();
  const releaseDateFilter = useDateFilter();

  const fetchArticles = async () => {
    const filters = {
      color: colorFilter.value,
      name: nameFilter.value,
      releaseDate: releaseDateFilter.value
    }
    const fetchedArticles = await API.getArticles(filters);
    setArticles(fetchedArticles)
  }

  const reset = () => {
    colorFilter.reset(); // will trigger a state update inside of useStringFilter
    nameFilter.reset(); // will trigger a state update inside of useStringFilter
    releaseDateFilter.reset(); // will trigger a state update inside of useDateFilter

    // fetchArticles will use old state of colorFilter, nameFilter and releaseDateFilter
    fetchArticles();
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

What to do now?

There is no straightforward way when using useEffect and useRef anymore, because we need to wait for multiple state updates to be completed. And that exactly is the actual problem!

The Solution

With a custom hook - namely useStateWithPromise - this problem can be solved:

const useStateWithPromise = (initialState) => {
  const [state, setState] = useState(initialState);
  const resolverRef = useRef(null);

  useEffect(() => {
    if (resolverRef.current) {
      resolverRef.current(state);
      resolverRef.current = null;
    }
    /**
     * Since a state update could be triggered with the exact same state again,
     * it's not enough to specify state as the only dependency of this useEffect.
     * That's why resolverRef.current is also a dependency, because it will guarantee,
     * that handleSetState was called in previous render
     */
  }, [resolverRef.current, state]);

  const handleSetState = useCallback((stateAction) => {
    setState(stateAction);
    return new Promise(resolve => {
      resolverRef.current = resolve;
    });
  }, [setState])

  return [state, handleSetState];
};
Enter fullscreen mode Exit fullscreen mode

It's not important to fully understand this hook. But what you should understand is that useStateWithPromise returns, just like useState, a getter and setter with a small important difference:

the setter returns a Promise, which we can await!

Now we can replace the useState statements in our custom filter hooks with useStateWithPromise:

const useStringFilter = (initialValue = "") => {
  const [value, setValue] = useStateWithPromise(initialValue);

  const reset = () => {
    // this will return a promise containing the updated state
    return setValue(initialValue);
  }

  return {
    value,
    setValue,
    reset
  }
}
Enter fullscreen mode Exit fullscreen mode

And then we can finally await state updates:

const FiltersSidebar = () => {
  // ...

  const reset =  async () => {
    // wait for all state updates to be completed
    await Promise.all([
      colorFilter.reset(),
      nameFilter.reset(),
      releaseDateFilter.reset()
    ]);

    // fetchArticles will STILL use old state of colorFilter, nameFilter and releaseDateFilter
    fetchArticles();
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Well, that was a WT.. moment for me, but it makes sense if you really think about how functional components work.

Viewing the code from plain JavaScript side (without react) reset is just a function inside of a function(al component). So each time the function is called (in the react terms: the functions is rerendered), reset will be a new function with a new reference. After we await the state updates of the filters with Promise.all, reset will still point to the exact same "old" fetchArticles reference, which is still pointing to "old" state! But in the meantime multiple state updates happend and there is much "newer" version of reset and also fetchArticles, which is pointing to the updated state.

Alt Text

With one additional state property, here resetted, this can be fixed:

const FiltersSidebar = () => {
  // ...
  const [resetted, setResetted] = useState(false)

  useEffect(() => {
    if(resetted){
      fetchArticles();
      setResetted(false);
    }
  },[resetted]);

  const reset =  async () => {
    await Promise.all([
      colorFilter.reset(),
      nameFilter.reset(),
      releaseDateFilter.reset()
    ]);

    setResetted(true);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now setResetted(true) will trigger a rerender of the component and it's guaranteed that the fetchArticles call inside of the useEffect statement will use the latest state for the API call.

The Solution ?

When I implemented useStateWithPromise I really thought that's the perfect solution and also questioned why there is no build-in solution for this in react? But after my WT.. moment I really understood why react didn't include such functionality:

It simply doesn't fit to the general design of functional components!

When you use class components, you work a lot with mutable references (e.g. this.state is reference that constantly gets updated by this.setState calls). But that's an anti pattern for functional components, because here you always try to work with immutable data and there is a reason for that:

Mutable references tend to cause unwanted side effects!

If your state has a non-primitive type (e.g. an object or array) it's recommended create new references instead of keeping the old one:

const MyComponent = () => {
  const [immutableData, setImmutableData] = useState({a: "a", b: "b"});
  let [mutableData, setMutableData] = useState({a: "a", b: "b"});


  const setNewData = () => {
    // good: new reference!
    setImmutableData({a: "new a", b: "new b"})

    // bad: same reference!
    mutableData.a = "new a";
    mutableData.b = "new b";
    setMutableData(mutableData)
  }

  useEffect(() => { console.log("immutable data changed") }, [immutableData])

  // will never be called because mutableData will always have same reference
  useEffect(() => { console.log("mutable data changed") }, [mutableData])

  return (
    <>
      <ChildComponent data={immutableData} />
      {/**
        * changing mutableData without the state setter, (e.g. mutableData.a = "new a")
        * could cause unwanted side effects, because ChildComponent wouldn't be rerendered,
        * so e.g. no useEffect statements inside ChildComponent would be triggered
        */}
      <ChildComponent data={mutableData} />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

To come back to the example:

  • each state update (e.g. reset of filter) causes a rerender
  • each rerender creates a new reference for reset and fetchArticles
  • each fetchArticles reference will point to a different immutable state
  • after the await in reset the fetchArticles call will use "old" state, because it's an "old" version of fetchArticles

So the general issue is that we have multiple fetchArticles versions (after each render) which all point to different states, because states in functional components are/should be immutable.

Conclusion

There is a reason why react didn't implement this feature for functional components. If you have the time and the ability to (re-)build the architecture of your app, you should really think about using useStateWithPromise.

I used it once in production, but only because the time was limited and my customer didn't want to spent to much time refactoring the code. For the next project I had a similiar problem but was able to switch the approach and solve this problem differently. E.g. in our example the actual problem was that we had multiple states in multiple hooks but could not reset them easily all at once (we needed to call reset on each filter). If the state of all filters would be in one place, it would be much easier to reset them all together. A different approach would be to store inital values in a ref so it's not even necessary to wait for the state to be updated.

As final conclusion: If you have the necessarity to await state updates in a manner like with useStateWithPromise you either have a non-ideal architechture, your requirments have changed or you have a really special case. 😉

Top comments (4)

Collapse
 
nvdung0616 profile image
NGUYỄN VĂN DŨNG • Edited

Hello Yusuf Aran,
Thank you so much, your article is very useful.
I'm new guy and started with react native project.
I got same problem with you in this your article.

Currently, I used useStateWithPromise to solve problem for temporaty in my project.

Do yo have any new approach to solve this problem ?

Collapse
 
ysfaran profile image
Yusuf Aran • Edited

Hey Nguyen!

I am happy to hear that you find my article useful.

As I stated in my conclusion, you should probably not use useStateWithPromise and no, unfortunatly I have no new or better solution yet.

I could try to help you with your case if you share some code (ideally with a codesandbox).

Collapse
 
wccrawford profile image
William Crawford

Thanks for all this, especially the explanation of why not to use it.

Collapse
 
ysfaran profile image
Yusuf Aran

You are welcome :)