Cómo cancelar una recuperación en componentWillUnmount

90

Creo que el título lo dice todo. La advertencia amarilla se muestra cada vez que desmonto un componente que todavía se está recuperando.

Consola

Advertencia: No se puede llamar setState(o forceUpdate) en un componente desmontado. Esta es una operación no operativa, pero ... Para solucionarlo, cancele todas las suscripciones y tareas asincrónicas en el 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
fuente
¿Qué advierte que no tengo ese problema?
nima moradi
pregunta actualizada
João Belo
¿
Prometiste
agregue el código de búsqueda a qustion
nima moradi

Respuestas:

80

Cuando dispara una Promise, pueden pasar unos segundos antes de que se resuelva y, en ese momento, el usuario podría haber navegado a otro lugar en su aplicación. Entonces, cuando Promise resuelve setStatese ejecuta en un componente desmontado y obtiene un error, como en su caso. Esto también puede provocar pérdidas de memoria.

Por eso es mejor sacar parte de su lógica asincrónica de los componentes.

De lo contrario, deberá cancelar de alguna manera su Promesa . Alternativamente, como técnica de último recurso (es un antipatrón), puede mantener una variable para verificar si el componente aún está montado:

componentDidMount(){
  this.mounted = true;

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

componentWillUnmount(){
  this.mounted = false;
}

Lo enfatizaré nuevamente: este es un antipatrón, pero puede ser suficiente en su caso (al igual que lo hicieron con la Formikimplementación).

Una discusión similar en GitHub

EDITAR:

Probablemente así es como resolvería el mismo problema (sin tener nada más que Reaccionar) con Hooks :

OPCION 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;
}

OPCIÓN B: Alternativamente con useRefque se comporta como una propiedad estática de una clase, lo que significa que no hace que el componente se vuelva a procesar cuando cambia su valor:

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
}

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

Tomasz Mularczyk
fuente
4
por lo que no hay una forma real de cancelar la recuperación en el componenteWillUnmount?
João Belo
1
Oh, no noté el código de tu respuesta antes, funcionó. gracias
João Belo
2
¿Qué quiere decir con "Por eso es mejor sacar la lógica asincrónica de los componentes"? ¿No es todo en react un componente?
Karpik
1
@Tomasz Mularczyk Muchas gracias, hicieron cosas valiosas.
KARTHIKEYAN.A
25

La gente amable de React recomienda envolver sus llamadas / promesas de búsqueda en una promesa cancelable. Si bien no hay ninguna recomendación en esa documentación para mantener el código separado de la clase o función con la recuperación, esto parece aconsejable porque es probable que otras clases y funciones necesiten esta funcionalidad, la duplicación de código es un anti-patrón, e independientemente del código persistente debe eliminarse o cancelarse en componentWillUnmount(). Según React, puede invocar cancel()la promesa envuelta componentWillUnmountpara evitar establecer el estado en un componente desmontado.

El código proporcionado se parecería a estos fragmentos de código si usamos React como guía:

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 ----

He descubierto que la respuesta dada puede no ser del todo correcta siguiendo el problema en GitHub. Aquí hay una versión que uso y que funciona para mis propósitos:

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;
        }
    };
};

La idea era ayudar al recolector de basura a liberar memoria haciendo que la función o lo que sea que uses sea nulo.

haleonj
fuente
¿Tiene el enlace al problema en github
Ren
@Ren, hay un sitio de GitHub para editar la página y discutir problemas.
haleonj
Ya no estoy seguro de dónde está el problema exacto en ese proyecto de GitHub.
haleonj
1
Enlace al problema de GitHub: github.com/facebook/react/issues/5465
sammalfix
22

Puede usar AbortController para cancelar una solicitud de recuperación.

Véase también: 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
fuente
2
Ojalá hubiera sabido que existe una API web para cancelar solicitudes como AbortController. Pero está bien, no es demasiado tarde para saberlo. Gracias.
Lex Soft
11

Desde que se abrió la publicación, se agregó una "recuperación abortable". https://developers.google.com/web/updates/2017/09/abortable-fetch

(de los documentos :)

El controlador + maniobra de señal Conozca AbortController y AbortSignal:

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

El controlador solo tiene un método:

controlador.abort (); Cuando haces esto, notifica la señal:

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

Esta API es proporcionada por el estándar DOM, y esa es la API completa. Es deliberadamente genérico, por lo que puede ser utilizado por otros estándares web y bibliotecas de JavaScript.

por ejemplo, así es como haría un tiempo de espera de recuperación después de 5 segundos:

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
fuente
Interesante, lo intentaré de esta manera. Pero antes de eso, leeré primero la API AbortController.
Lex Soft
¿Podemos usar solo una instancia de AbortController para múltiples recuperaciones de modo que cuando invocamos el método de aborto de este único AbortController en el componentWillUnmount, cancelará todas las recuperaciones existentes en nuestro componente? Si no es así, significa que tenemos que proporcionar diferentes instancias de AbortController para cada una de las recuperaciones, ¿verdad?
Lex Soft
3

El quid de esta advertencia es que su componente tiene una referencia que se encuentra en alguna devolución de llamada / promesa pendiente.

Para evitar el antipatrón de mantener su estado isMounted (que mantiene vivo su componente) como se hizo en el segundo patrón, el sitio web react sugiere usar una promesa opcional ; sin embargo, ese código también parece mantener vivo su objeto.

En cambio, lo hice usando un cierre con una función enlazada anidada para setState.

Aquí está mi constructor (mecanografiado) ...

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
fuente
3
Esto es conceptualmente diferente de mantener una bandera isMounted, que sólo le está uniéndose a la clausura en lugar de colgarlo dethis
AnilRedshift
2

Cuando necesito "cancelar todas las suscripciones y de forma asíncrona", normalmente envío algo a redux en componentWillUnmount para informar a todos los demás suscriptores y enviar una solicitud más sobre la cancelación al servidor si es necesario

Sasha Kos
fuente
2

Creo que si no es necesario informar al servidor sobre la cancelación, el mejor enfoque es usar la sintaxis async / await (si está disponible).

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
fuente
0

Además de los ejemplos de ganchos de promesa cancelables en la solución aceptada, puede ser útil tener un useAsyncCallbackgancho que envuelva una devolución de llamada de solicitud y devuelva una promesa cancelable. La idea es la misma, pero con un gancho que funciona como un normal useCallback. A continuación, se muestra un ejemplo de implementación:

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
fuente
-2

Creo que encontré una forma de evitarlo. El problema no es tanto la búsqueda en sí, sino el setState después de que se descarta el componente. Entonces, la solución fue establecer this.state.isMountedcomo falsey luego componentWillMountcambiarlo a verdadero, y luego componentWillUnmountestablecerlo a falso nuevamente. Luego, solo if(this.state.isMounted)el setState dentro de la búsqueda. Al igual que:

  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
fuente
3
setState probablemente no sea ideal, ya que no actualizará el valor en estado inmediatamente.
LeonF