Comment annuler une récupération sur le composantWillUnmount

90

Je pense que le titre dit tout. L'avertissement jaune s'affiche chaque fois que je démonte un composant en cours de récupération.

Console

Avertissement: impossible d'appeler setState(ou forceUpdate) sur un composant non monté. Ce n'est pas une opération, mais ... Pour corriger, annulez tous les abonnements et les tâches asynchrones dans la componentWillUnmountméthode.

  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
la source
qu'est-ce que c'est avertissement que je n'ai pas ce problème
nima moradi
question mise à jour
João Belo
avez-vous promis ou code asynchrone pour récupérer
nima moradi
ajoutez-vous chercher le code à qustion
nima moradi

Réponses:

80

Lorsque vous lancez une promesse, la résolution de la promesse peut prendre quelques secondes et à ce moment-là, l'utilisateur peut avoir navigué vers un autre endroit de votre application. Ainsi, lorsque Promise résout setStateest exécuté sur un composant non monté et vous obtenez une erreur - comme dans votre cas. Cela peut également provoquer des fuites de mémoire.

C'est pourquoi il est préférable de déplacer une partie de votre logique asynchrone hors des composants.

Sinon, vous devrez annuler d'une manière ou d'une autre votre promesse . Alternativement - en dernier recours (c'est un anti-modèle) - vous pouvez conserver une variable pour vérifier si le composant est toujours monté:

componentDidMount(){
  this.mounted = true;

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

componentWillUnmount(){
  this.mounted = false;
}

Je le soulignerai encore une fois - c'est un anti-modèle mais peut être suffisant dans votre cas (tout comme ils l'ont fait avec la Formikmise en œuvre).

Une discussion similaire sur GitHub

ÉDITER:

C'est probablement ainsi que je résoudrais le même problème (n'ayant que React) avec Hooks :

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

OPTION B: Alternativement avec useRefqui se comporte comme une propriété statique d'une classe, ce qui signifie qu'il ne rend pas le composant lorsque sa valeur change:

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
}

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

Tomasz Mularczyk
la source
4
il n'y a donc pas de moyen réel d'annuler simplement la récupération sur le composantWillUnmount?
João Belo
1
Oh, je n'ai pas remarqué le code de votre réponse avant, cela a fonctionné. merci
João Belo
2
qu'entendez-vous par "C'est pourquoi il est préférable de déplacer votre logique asynchrone hors des composants."? Tout n'est-il pas en réaction un composant?
Karpik
1
@Tomasz Mularczyk Merci beaucoup, vous avez fait des choses dignes.
KARTHIKEYAN.A
25

Les gens sympathiques de React recommandent d' encapsuler vos appels / promesses de récupération dans une promesse annulable. Bien qu'il n'y ait aucune recommandation dans cette documentation de garder le code séparé de la classe ou de la fonction avec l'extraction, cela semble souhaitable car d'autres classes et fonctions auront probablement besoin de cette fonctionnalité, la duplication de code est un anti-modèle, et quel que soit le code persistant doit être éliminé ou annulé dans componentWillUnmount(). Comme pour React, vous pouvez appeler cancel()la promesse encapsulée componentWillUnmountpour éviter de définir l'état d'un composant non monté.

Le code fourni ressemblerait à ces extraits de code si nous utilisons React comme guide:

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

---- ÉDITER ----

J'ai trouvé que la réponse donnée peut ne pas être tout à fait correcte en suivant le problème sur GitHub. Voici une version que j'utilise qui fonctionne pour mes besoins:

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

L'idée était d'aider le garbage collector à libérer de la mémoire en rendant la fonction ou tout ce que vous utilisez nul.

haleonj
la source
avez-vous le lien vers le problème sur github
Ren
@Ren, il existe un site GitHub pour éditer la page et discuter des problèmes.
haleonj
Je ne sais plus où se trouve le problème exact sur ce projet GitHub.
haleonj
1
Lien vers le problème GitHub: github.com/facebook/react/issues/5465
sammalfix
22

Vous pouvez utiliser AbortController pour annuler une demande de récupération.

Voir aussi: 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
la source
2
J'aurais aimé savoir qu'il existe une API Web pour annuler les demandes comme AbortController. Mais d'accord, il n'est pas trop tard pour le savoir. Merci.
Lex Soft
11

Depuis que le message a été ouvert, un "abortable-fetch" a été ajouté. https://developers.google.com/web/updates/2017/09/abortable-fetch

(à partir de la documentation :)

Le contrôleur + la manœuvre de signal Rencontrez AbortController et AbortSignal:

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

Le contrôleur n'a qu'une seule méthode:

controller.abort (); Lorsque vous faites cela, il notifie le signal:

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

Cette API est fournie par le standard DOM, et c'est l'API entière. Il est délibérément générique et peut donc être utilisé par d'autres standards Web et bibliothèques JavaScript.

par exemple, voici comment définir un délai de récupération après 5 secondes:

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
la source
Intéressant, je vais essayer de cette façon. Mais avant cela, je vais d'abord lire l'API AbortController.
Lex Soft
Pouvons-nous utiliser une seule instance AbortController pour plusieurs récupérations de sorte que lorsque nous invoquons la méthode d'abort de cet unique AbortController dans le composantWillUnmount, elle annulera toutes les récupérations existantes dans notre composant? Sinon, cela signifie que nous devons fournir différentes instances AbortController pour chacune des récupérations, non?
Lex Soft le
3

Le nœud de cet avertissement est que votre composant a une référence à lui qui est détenue par un rappel / promesse exceptionnelle.

Pour éviter l'anti-modèle de garder votre état isMounted autour (qui maintient votre composant en vie) comme cela a été fait dans le deuxième modèle, le site Web react suggère d' utiliser une promesse facultative ; cependant, ce code semble également maintenir votre objet en vie.

Au lieu de cela, je l'ai fait en utilisant une fermeture avec une fonction liée imbriquée à setState.

Voici mon constructeur (dactylographié)…

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
la source
3
Ce n'est pas différent du point de vue conceptuel que de garder un drapeau isMounted, mais vous le liez à la fermeture au lieu de le suspendrethis
AnilRedshift
2

Lorsque j'ai besoin "d'annuler tous les abonnements et de manière asynchrone", j'envoie généralement quelque chose à reduxer dans componentWillUnmount pour informer tous les autres abonnés et envoyer une autre demande d'annulation au serveur si nécessaire

Sasha Kos
la source
2

Je pense que s'il n'est pas nécessaire d'informer le serveur de l'annulation, la meilleure approche consiste simplement à utiliser la syntaxe async / await (si elle 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
la source
0

En plus des exemples de hooks de promesse annulable dans la solution acceptée, il peut être pratique d'avoir un useAsyncCallbackhook encapsulant un rappel de demande et renvoyant une promesse annulable. L'idée est la même, mais avec un crochet fonctionnant comme un habitué useCallback. Voici un exemple de mise en œuvre:

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
la source
-2

Je pense avoir trouvé un moyen de contourner cela. Le problème n'est pas tant la récupération elle-même que le setState après le rejet du composant. La solution était donc de définir this.state.isMountedcomme falseet puis de le componentWillMountchanger sur true, et de componentWillUnmountdéfinir à nouveau sur false. Ensuite, juste if(this.state.isMounted)le setState à l'intérieur de la récupération. Ainsi:

  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
la source
3
setState n'est probablement pas idéal, car il ne mettra pas à jour immédiatement la valeur dans state.
LeonF