DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Cancelling a Promise with React.useEffect
Julian Garamendy
Julian Garamendy

Posted on • Updated on • Originally published at juliangaramendy.dev

Cancelling a Promise with React.useEffect

If you landed here in 2021, please note that this article is two years old. Since then, we've learned to use swr and react-query to fetch and cache server data.

I've seen it done in complicated ways so I have to write this down.

Quick Example

function BananaComponent() {

  const [bananas, setBananas] = React.useState([])

  React.useEffect(() => {
    let isSubscribed = true
    fetchBananas().then( bananas => {
      if (isSubscribed) {
        setBananas(bananas)
      }
    })
    return () => isSubscribed = false
  }, []);

  return (
    <ul>
    {bananas.map(banana => <li>{banana}</li>)}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the fetchBananas function returns a promise. We can "cancel" the promise by having a conditional in the scope of useEffect, preventing the app from setting state after the component has unmounted.

Long Explanation

Imagine we have a REST API endpoint that gives us a list of bananas. We can get the list by using fetch which returns a promise. We wrap the call in a nice async function which naturally returns a promise.

async function fetchBananas() {

  return fetch('/api/bananas/')
    .then(res => {
      if (res.status >= 400) {
        throw new Error("Bad response from server")
      }
    })
    .then(res => {
      return res.data
    })

}
Enter fullscreen mode Exit fullscreen mode

Now we want to render some bananas in a React function component. In a traditional class component we would make the async call in componentWillMount or componentDidMount, but with function components we need to use the useEffect hook.

Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI. (reacjs docs)

Our BananaComponent would look like this:

function BananaComponent() {

  const [bananas, setBananas] = React.useState([])

  React.useEffect(() => {
    fetchBananas().then(setBananas)
  }, []);

  return (
    <ul>
    {bananas.map(banana => <li>{banana}</li>)}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

With useState([]) we define an initial value of for bananas so we can render an empty list while the promise is pending. The useEffect function takes two arguments: the first one is the effect function, and the second is the "dependencies" or "inputs". Our effect function "subscribes" to the promise. For our second argument we pass an empty array so that the effect only runs once. Then, when the data is retrieved, the promise resolves, and our useEffect calls setBananas, which causes our function component to re-render, this time with some bananas in the array.

Wait! Is that it?

Unfortunately not. Our component "subscribes" to the promise, but it never "unsubscribes" or cancels the request. If for any reason, our component is unmounted before the promise resolves, our code will try to "set state" (calling setBananas) on an unmounted component. This will throw a warning:

Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
Enter fullscreen mode Exit fullscreen mode

We can fix this by cancelling our request when the component unmounts. In function components, this is done in the cleanup function of useEffect.

  ...

  React.useEffect(() => {
    fetchBananas().then(setBananas)
    return () => someHowCancelFetchBananas! <<<<<<
  }, []);

  ...

Enter fullscreen mode Exit fullscreen mode

But we can't cancel a promise. What we can do is prevent our code from setting state if the component has been unmounted.

In the past there was isMounted, but as it turns out, it's an anti-pattern. With class components we could get away with implementing our own this._isMounted; but in function components there are no instance variables.

I've seen some implementations using useRef to keep a mountedRef.

But there's an easier way.

Taking advantage of closures we can keep an isSubscribed boolean inside useEffect.

function BananaComponent() {

  const [bananas, setBananas] = React.useState([])

  React.useEffect(() => {
    let isSubscribed = true
    fetchBananas().then( bananas => {
      if (isSubscribed) {
        setBananas(bananas)
      }
    })
    return () => isSubscribed = false
  }, []);

  ...

Enter fullscreen mode Exit fullscreen mode

We start with isSubscribed set to true, then we add a conditional before calling setBananas and finally, we set isSubscribed to false in the cleanup function.

Is that it?

YES; that's all we need.

We can improve the above code by handling the promise being pending, and when it's rejected.

function BananaComponent() {

  const [bananas, setBananas] = React.useState(undefined);
  const [error, setError] = React.useState('');

  React.useEffect(() => {
    let isSubscribed = true;
    fetchBananas()
      .then(bananas => (isSubscribed ? setBananas(bananas) : null))
      .catch(error => (isSubscribed ? setError(error.toString()) : null));

    return () => (isSubscribed = false);
  }, []);

  render (
    <ul>
    {!error && !bananas && <li className="loading">loading...</li>)}
    {!error && bananas && bananas.map(banana => <li>{banana}</li>)}
    {error && <li className="error">{error}</li>}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Or even better...

We can create a custom hook where we return a tuple like [value, error, isPending].

Hang on! Please note that this article is two years old. Since then, we've learned to use swr and react-query to fetch and cache server data. We don't need the code below. The libraries mentioned above handle this and all the edge cases.

In the implementation below, the consumer doesn't need to keep its own state, and the 'pending' state is explicit.

export function usePromiseSubscription(promiseOrFunction, defaultValue, deps) {
  const [state, setState] = React.useState({ value: defaultValue, error: null, isPending: true })

  React.useEffect(() => {
    const promise = (typeof promiseOrFunction === 'function')
      ? promiseOrFunction()
      : promiseOrFunction

    let isSubscribed = true
    promise
      .then(value => isSubscribed ? setState({ value, error: null, isPending: false }) : null)
      .catch(error => isSubscribed ? setState({ value: defaultValue, error: error, isPending: false }) : null)

    return () => (isSubscribed = false)
  }, deps)

  const { value, error, isPending } = state
  return [value, error, isPending]
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function BananaComponent() {

  const [bananas, error, pending] = usePromiseSubscription(fetchBananas, [], [])

  render (
    <ul>
    {pending && <li className="loading">loading...</li>)}
    {!pending && !error && bananas.map(banana => <li>{banana}</li>)}
    {error && <li className="error">{error}</li>}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

I hope this was useful.

Questions? Comments?

I would love to hear your thoughts.

  • Can you see anything wrong with this approach?
  • Is this better than what you were doing before?
  • Is it worse?
  • I'm not entirely happy with the [value, error, isPending] tuple. Can you think of a better "API" for this?

This article was originally posted in my personal blog: https://juliangaramendy.dev/use-promise-subscription/


Photo by Alex on Unsplash

Top comments (2)

Collapse
squigglybob profile image
squigglybob

Nice post :)

Collapse
sebastienlorber profile image
Sebastien Lorber

Seems to handle race condtions :)

But you can easily add in-flight request abortion

dev.to/sebastienlorber/handling-ap...

🌚 Browsing with dark mode makes you a better developer by a factor of exactly 40.

It's a scientific fact.