Como cancelar uma busca em componentWillUnmount

90

Acho que o título diz tudo. O aviso amarelo é exibido sempre que eu desmonto um componente que ainda está em busca.

Console

Aviso: Não é possível chamar setState(ou forceUpdate) em um componente não montado. Este é um ambiente autônomo, mas ... Para corrigir, cancele todas as assinaturas e tarefas assíncronas no componentWillUnmountmétodo.

  constructor(props){
    super(props);
    this.state = {
      isLoading: true,
      dataSource: [{
        name: 'loading...',
        id: 'loading',
      }]
    }
  }

  componentDidMount(){
    return fetch('LINK HERE')
      .then((response) => response.json())
      .then((responseJson) => {
        this.setState({
          isLoading: false,
          dataSource: responseJson,
        }, function(){
        });
      })
      .catch((error) =>{
        console.error(error);
      });
  }
João belo
fonte
o que é isso avisando que eu não tenho esse problema
nima moradi
pergunta atualizada
João Belo
você prometeu ou código assíncrono para buscar
nima moradi
adicione seu código de busca a qustion
nima moradi

Respostas:

80

Quando você dispara uma promessa, pode levar alguns segundos antes que ela seja resolvida e, nessa altura, o usuário pode ter navegado para outro local em seu aplicativo. Portanto, quando o Promise resolve setStateé executado em um componente não montado e você obtém um erro - como no seu caso. Isso também pode causar vazamentos de memória.

É por isso que é melhor remover parte de sua lógica assíncrona dos componentes.

Caso contrário, você precisará cancelar sua promessa de alguma forma . Alternativamente - como técnica de último recurso (é um antipadrão) - você pode manter uma variável para verificar se o componente ainda está montado:

componentDidMount(){
  this.mounted = true;

  this.props.fetchData().then((response) => {
    if(this.mounted) {
      this.setState({ data: response })
    }
  })
}

componentWillUnmount(){
  this.mounted = false;
}

Vou enfatizar isso novamente - este é um antipadrão, mas pode ser suficiente no seu caso (assim como fizeram com a Formikimplementação).

Uma discussão semelhante no GitHub

EDITAR:

Provavelmente é assim que eu resolveria o mesmo problema (não tendo nada além de React) com Ganchos :

OPÇÃO A:

import React, { useState, useEffect } from "react";

export default function Page() {
  const value = usePromise("https://something.com/api/");
  return (
    <p>{value ? value : "fetching data..."}</p>
  );
}

function usePromise(url) {
  const [value, setState] = useState(null);

  useEffect(() => {
    let isMounted = true; // track whether component is mounted

    request.get(url)
      .then(result => {
        if (isMounted) {
          setState(result);
        }
      });

    return () => {
      // clean up
      isMounted = false;
    };
  }, []); // only on "didMount"

  return value;
}

OPÇÃO B: Alternativamente, com o useRefqual se comporta como uma propriedade estática de uma classe, o que significa que não faz o componente rerender quando seu valor muda:

function usePromise2(url) {
  const isMounted = React.useRef(true)
  const [value, setState] = useState(null);


  useEffect(() => {
    return () => {
      isMounted.current = false;
    };
  }, []);

  useEffect(() => {
    request.get(url)
      .then(result => {
        if (isMounted.current) {
          setState(result);
        }
      });
  }, []);

  return value;
}

// or extract it to custom hook:
function useIsMounted() {
  const isMounted = React.useRef(true)

  useEffect(() => {
    return () => {
      isMounted.current = false;
    };
  }, []);

  return isMounted; // returning "isMounted.current" wouldn't work because we would return unmutable primitive
}

Exemplo: https://codesandbox.io/s/86n1wq2z8

Tomasz Mularczyk
fonte
4
portanto, não há uma maneira real de simplesmente cancelar a busca no componenteWillUnmount?
João Belo
1
Oh, eu não percebi o código da sua resposta antes, funcionou. obrigado
João Belo
2
o que você quer dizer com "É por isso que é melhor remover a lógica assíncrona dos componentes"? Não é tudo na reação um componente?
Karpik
1
@Tomasz Mularczyk Muito obrigado, você fez coisas valiosas.
KARTHIKEYAN.A
25

O pessoal amigável do React recomenda envolver suas chamadas / promessas de busca em uma promessa cancelável. Embora não haja nenhuma recomendação nessa documentação para manter o código separado da classe ou função com a busca, isso parece aconselhável porque outras classes e funções provavelmente precisarão dessa funcionalidade, a duplicação de código é um antipadrão e independentemente do código remanescente deve ser descartado ou cancelado em componentWillUnmount(). De acordo com React, você pode chamar cancel()a promessa encapsulada em componentWillUnmountpara evitar definir o estado em um componente não montado.

O código fornecido seria semelhante a estes snippets de código se usarmos o React como um guia:

const makeCancelable = (promise) => {
    let hasCanceled_ = false;

    const wrappedPromise = new Promise((resolve, reject) => {
        promise.then(
            val => hasCanceled_ ? reject({isCanceled: true}) : resolve(val),
            error => hasCanceled_ ? reject({isCanceled: true}) : reject(error)
        );
    });

    return {
        promise: wrappedPromise,
        cancel() {
            hasCanceled_ = true;
        },
    };
};

const cancelablePromise = makeCancelable(fetch('LINK HERE'));

constructor(props){
    super(props);
    this.state = {
        isLoading: true,
        dataSource: [{
            name: 'loading...',
            id: 'loading',
        }]
    }
}

componentDidMount(){
    cancelablePromise.
        .then((response) => response.json())
        .then((responseJson) => {
            this.setState({
                isLoading: false,
                dataSource: responseJson,
            }, () => {

            });
        })
        .catch((error) =>{
            console.error(error);
        });
}

componentWillUnmount() {
    cancelablePromise.cancel();
}

---- EDITAR ----

Eu descobri que a resposta dada pode não estar totalmente correta, acompanhando o problema no GitHub. Aqui está uma versão que uso e que funciona para meus objetivos:

export const makeCancelableFunction = (fn) => {
    let hasCanceled = false;

    return {
        promise: (val) => new Promise((resolve, reject) => {
            if (hasCanceled) {
                fn = null;
            } else {
                fn(val);
                resolve(val);
            }
        }),
        cancel() {
            hasCanceled = true;
        }
    };
};

A ideia era ajudar o coletor de lixo a liberar memória tornando a função ou qualquer outra coisa nula.

haleonj
fonte
você tem o link para o problema no github
Ren
@Ren, existe um site GitHub para editar a página e discutir questões.
haleonj
Não tenho mais certeza de onde está o problema exato naquele projeto GitHub.
haleonj
1
Link para o problema do GitHub: github.com/facebook/react/issues/5465
sammalfix
22

Você pode usar AbortController para cancelar uma solicitação de busca.

Veja também: https://www.npmjs.com/package/abortcontroller-polyfill

class FetchComponent extends React.Component{
  state = { todos: [] };
  
  controller = new AbortController();
  
  componentDidMount(){
    fetch('https://jsonplaceholder.typicode.com/todos',{
      signal: this.controller.signal
    })
    .then(res => res.json())
    .then(todos => this.setState({ todos }))
    .catch(e => alert(e.message));
  }
  
  componentWillUnmount(){
    this.controller.abort();
  }
  
  render(){
    return null;
  }
}

class App extends React.Component{
  state = { fetch: true };
  
  componentDidMount(){
    this.setState({ fetch: false });
  }
  
  render(){
    return this.state.fetch && <FetchComponent/>
  }
}

ReactDOM.render(<App/>, document.getElementById('root'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="root"></div>

Paduado
fonte
2
I wish I had known that there is a Web API for canceling requests like AbortController. But alright, it's not too late to know it. Thank you.
Lex Soft
11

Since the post had been opened, an "abortable-fetch" has been added. https://developers.google.com/web/updates/2017/09/abortable-fetch

(from the docs:)

The controller + signal manoeuvre Meet the AbortController and AbortSignal:

const controller = new AbortController();
const signal = controller.signal;

The controller only has one method:

controller.abort(); When you do this, it notifies the signal:

signal.addEventListener('abort', () => {
  // Logs true:
  console.log(signal.aborted);
});

This API is provided by the DOM standard, and that's the entire API. It's deliberately generic so it can be used by other web standards and JavaScript libraries.

for example, here's how you'd make a fetch timeout after 5 seconds:

const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 5000);

fetch(url, { signal }).then(response => {
  return response.text();
}).then(text => {
  console.log(text);
});
Ben Yitzhaki
fonte
Interesting, I will try this way. But prior to that, I will read the AbortController API first.
Lex Soft
Can we use just one AbortController instance for multiple fetches such that when we invoke the abort method of this single AbortController in the componentWillUnmount, it will cancel all of the existing fetches in our component ? If not, it means we have to provide different AbortController instances for each of the fetches, right ?
Lex Soft
3

The crux of this warning is that your component has a reference to it that is held by some outstanding callback/promise.

To avoid the antipattern of keeping your isMounted state around (which keeps your component alive) as was done in the second pattern, the react website suggests using an optional promise; however that code also appears to keep your object alive.

Instead, I've done it by using a closure with a nested bound function to setState.

Here's my constructor(typescript)…

constructor(props: any, context?: any) {
    super(props, context);

    let cancellable = {
        // it's important that this is one level down, so we can drop the
        // reference to the entire object by setting it to undefined.
        setState: this.setState.bind(this)
    };

    this.componentDidMount = async () => {
        let result = await fetch(…);            
        // ideally we'd like optional chaining
        // cancellable.setState?.({ url: result || '' });
        cancellable.setState && cancellable.setState({ url: result || '' });
    }

    this.componentWillUnmount = () => {
        cancellable.setState = undefined; // drop all references.
    }
}
Anthony Wieser
fonte
3
This is conceptually no different than keeping an isMounted flag, only you're binding it to the closure instead of hanging it of this
AnilRedshift
2

When I need to "cancel all subscriptions and asynchronous" I usually dispatch something to redux in componentWillUnmount to inform all other subscribers and send one more request about cancellation to server if necessary

Sasha Kos
fonte
2

I think if it is not necessary to inform server about cancellation - best approach is just to use async/await syntax (if it is available).

constructor(props){
  super(props);
  this.state = {
    isLoading: true,
    dataSource: [{
      name: 'loading...',
      id: 'loading',
    }]
  }
}

async componentDidMount() {
  try {
    const responseJson = await fetch('LINK HERE')
      .then((response) => response.json());

    this.setState({
      isLoading: false,
      dataSource: responseJson,
    }
  } catch {
    console.error(error);
  }
}
Sasha Kos
fonte
0

In addition to the cancellable promise hooks examples in the accepted solution, it can be handy to have a useAsyncCallback hook wrapping a request callback and returning a cancellable promise. The idea is the same, but with a hook working just like a regular useCallback. Here is an example of implementation:

function useAsyncCallback<T, U extends (...args: any[]) => Promise<T>>(callback: U, dependencies: any[]) {
  const isMounted = useRef(true)

  useEffect(() => {
    return () => {
      isMounted.current = false
    }
  }, [])

  const cb = useCallback(callback, dependencies)

  const cancellableCallback = useCallback(
    (...args: any[]) =>
      new Promise<T>((resolve, reject) => {
        cb(...args).then(
          value => (isMounted.current ? resolve(value) : reject({ isCanceled: true })),
          error => (isMounted.current ? reject(error) : reject({ isCanceled: true }))
        )
      }),
    [cb]
  )

  return cancellableCallback
}
Thomas Jgenti
fonte
-2

I think I figured a way around it. The problem is not as much the fetching itself but the setState after the component is dismissed. So the solution was to set this.state.isMounted as false and then on componentWillMount change it to true, and in componentWillUnmount set to false again. Then just if(this.state.isMounted) the setState inside the fetch. Like so:

  constructor(props){
    super(props);
    this.state = {
      isMounted: false,
      isLoading: true,
      dataSource: [{
        name: 'loading...',
        id: 'loading',
      }]
    }
  }

  componentDidMount(){
    this.setState({
      isMounted: true,
    })

    return fetch('LINK HERE')
      .then((response) => response.json())
      .then((responseJson) => {
        if(this.state.isMounted){
          this.setState({
            isLoading: false,
            dataSource: responseJson,
          }, function(){
          });
        }
      })
      .catch((error) =>{
        console.error(error);
      });
  }

  componentWillUnmount() {
    this.setState({
      isMounted: false,
    })
  }
João Belo
fonte
3
setState is probably not ideal, since it won't update the value in state immediately.
LeonF