36
votes

L'événement useState + useEffect + de React Hooks donne un état périmé

J'essaie d'utiliser un émetteur d'événement avec React useEffect et useState , mais il obtient toujours l'état initial au lieu de l'état mis à jour. Cela fonctionne si j'appelle directement le gestionnaire d'événements, même avec un setTimeout .

Si je passe la valeur au 2ème argument useEffect() , cela le fait fonctionner, mais cela provoque un réabonnement à l'émetteur d'événement à chaque fois que la valeur change (qui est déclenchée par des frappes).

Qu'est-ce que je fais mal? J'ai essayé useState , useRef , useReducer et useCallback , et je n'ai pas réussi à faire fonctionner.

Voici une reproduction:

import React, { useState, useEffect } from "react";
import { Controlled as CodeMirror } from "react-codemirror2";
import "codemirror/lib/codemirror.css";
import EventEmitter from "events";

let ee = new EventEmitter();

const initialValue = "initial value";

function App(props) {
  const [value, setValue] = useState(initialValue);

  // Should get the latest value, both after the initial server load, and whenever the Codemirror input changes.
  const handleEvent = (msg, data) => {
    console.info("Value in event handler: ", value);
    // This line is only for demoing the problem. If we wanted to modify the DOM in this event, we would instead call some setState function and rerender in a React-friendly fashion.
    document.getElementById("result").innerHTML = value;
  };

  // Get value from server on component creation (mocked)
  useEffect(() => {
    setTimeout(() => {
      setValue("value from server");
    }, 1000);
  }, []);

  // Subscribe to events on component creation
  useEffect(() => {
    ee.on("some_event", handleEvent);
    return () => {
      ee.off(handleEvent);
    };
  }, []);

  return (
    <React.Fragment>
      <CodeMirror
        value={value}
        options={{ lineNumbers: true }}
        onBeforeChange={(editor, data, newValue) => {
          setValue(newValue);
        }}
      />
      {/* Everything below is only for demoing the problem. In reality the event would come from some other source external to this component. */}
      <button
        onClick={() => {
          ee.emit("some_event");
        }}
      >
        EventEmitter (doesnt work)
      </button>
      <div id="result" />
    </React.Fragment>
  );
}

export default App;

Voici un sandbox de code avec le même dans App2 :

https://codesandbox.io/s/ww2v80ww4l

App composant d' App a 3 implémentations différentes - EventEmitter, pubsub-js et setTimeout. Seul setTimeout fonctionne.

Éditer

Pour clarifier mon objectif, je veux simplement que la valeur de handleEvent corresponde à la valeur Codemirror dans tous les cas. Lorsqu'un bouton est cliqué, la valeur actuelle du miroir de code doit être affichée. Au lieu de cela, la valeur initiale est affichée.


2 commentaires

Cela n'a rien à voir avec les crochets. useEffect s'exécute de manière asynchrone. Et la méthode handleEvent mute directement quelque chose dans le DOM. La mutation DOM se produit donc avant que vous n'obteniez la valeur du serveur. Si vous le faites de la manière React de rendre basé sur la valeur de l'état, cela ne se produira pas.


@Dinesh Dans mon vrai code, je ne modifie pas le DOM dans le gestionnaire d'événements. C'était juste pour démo du problème.


3 Réponses :


3
votes

Réponse mise à jour.

Le problème n'est pas avec les crochets. La valeur de l'état initial a été fermée et transmise à EventEmitter et a été utilisée encore et encore.

Ce n'est pas une bonne idée d'utiliser des valeurs d'état directement dans handleEvent . Au lieu de cela, nous devons les transmettre en tant que paramètres lors de l'émission de l'événement.

import React, { useState, useEffect } from "react";
import { Controlled as CodeMirror } from "react-codemirror2";
import "codemirror/lib/codemirror.css";
import EventEmitter from "events";

let ee = new EventEmitter();

const initialValue = "initial value";

function App(props) {
  const [value, setValue] = useState(initialValue);
  const [isReady, setReady] = useState(false);

  // Should get the latest value
  function handleEvent(value, msg, data) {
    // Do not use state values in this handler
    // the params are closed and are executed in the context of EventEmitter
    // pass values as parameters instead
    console.info("Value in event handler: ", value);
    document.getElementById("result").innerHTML = value;
  }

  // Get value from server on component creation (mocked)
  useEffect(() => {
    setTimeout(() => {
      setValue("value from server");
      setReady(true);
    }, 1000);
  }, []);

  // Subscribe to events on component creation
  useEffect(
    () => {
      if (isReady) {
        ee.on("some_event", handleEvent);
      }
      return () => {
        if (!ee.off) return;
        ee.off(handleEvent);
      };
    },
    [isReady]
  );

  function handleClick(e) {
    ee.emit("some_event", value);
  }

  return (
    <React.Fragment>
      <CodeMirror
        value={value}
        options={{ lineNumbers: true }}
        onBeforeChange={(editor, data, newValue) => {
          setValue(newValue);
        }}
      />
      <button onClick={handleClick}>EventEmitter (works now)</button>
      <div id="result" />
    </React.Fragment>
  );
}

export default App;

Voici un code et une boîte de travail


7 commentaires

Merci de votre aide. J'ai mis votre code dans un nouveau bac à sable ici: codesandbox.io/s/oo159k4w5y ( App3 ). Cela fonctionne pour charger la valeur du serveur, mais l'objectif est d'obtenir également les modifications Codemirror, donc si vous modifiez l'entrée codemirror, vous obtenez toujours la valeur du serveur d'origine. Je l'ai fait fonctionner d'une autre manière, en mettant le gestionnaire d'événements dans l'état: codesandbox.io/s/6jq3r6vnjw ( App ). Pensées?


@TonyR Je ne suis pas sûr de bien comprendre la fonctionnalité souhaitée. - Qu'entendez-vous par «œuvres»? - Qu'entendez-vous par «ne fonctionne pas»?


Lorsque vous cliquez sur un bouton, la valeur du gestionnaire d'événements doit être identique à la valeur actuelle du codemirror.


@TonyR J'ai découvert le problème. Cela n'avait rien à voir avec les crochets ou React. La méthode handleEvent été fermée avec la valeur d'état initiale et transmise à EventEmitter et la même valeur a été utilisée tout le temps. Fermetures. J'ai mis à jour la solution.


C'est intéressant. Cependant, le but de l'émetteur d'événements est que je peux émettre des événements à partir d'un composant différent de celui-ci. Un composant différent déclenchant l'événement n'aura pas accès à la value . Oui, je sais qu'il existe d'autres façons de faire cela (contexte, Redux, forage de prop). Je voulais le faire fonctionner avec un modèle d'émetteur d'événements ...


Gardez à l'esprit qu'il ne s'agit peut-être même pas d'un composant déclenchant les événements. Supposons que j'utilise une bibliothèque basée sur les événements et que je veux qu'un composant reçoive l'événement et utilise son propre état pour faire quelque chose (supposons que "quelque chose" est compatible avec React; il ne modifiera pas le DOM). C'est plus simple dans Redux, c'est sûr.


Je pense que le fait est que, peu importe d'où vous déclenchez les événements, l'émetteur devrait inclure la charge utile et ne pas se fier à des données provenant de sources extérieures. Peut-être qu'un magasin de données comme redux vous aidera car il vous permettra d'obtenir les données du magasin en dehors d'un composant React. Mais la bonne approche sera de concevoir une implémentation où les données ne dépendent pas de sources extérieures et sont transmises comme charge utile.



49
votes

value est périmée dans le gestionnaire d'événements car elle obtient sa valeur de la fermeture où elle a été définie. À moins que nous ne réinscrivions un nouveau gestionnaire d'événements à chaque changement de value , il n'obtiendra pas la nouvelle valeur.

Solution 1: ajoutez le deuxième argument à l'effet de publication [value] . Cela permet au gestionnaire d'événements d'obtenir la valeur correcte, mais provoque également la réexécution de l'effet à chaque frappe.

Solution 2: utilisez une ref pour stocker la dernière value dans une variable d'instance de composant. Ensuite, créez un effet qui ne fait que mettre à jour cette variable chaque fois value état de la value change. Dans le gestionnaire d'événements, utilisez la ref et non la value .

const [value, setValue] = useState(initialValue);
const refValue = useRef(value);
useEffect(() => {
    refValue.current = value;
});
const handleEvent = (msg, data) => {
    console.info("Value in event handler: ", refValue.current);
};

https://reactjs.org/docs/hooks-faq.html#what-can-i-do-if-my-effect-dependencies-change-too-often

On dirait qu'il existe d'autres solutions sur cette page qui pourraient également fonctionner. Un grand merci à @Dinesh pour son aide.


2 commentaires

Je viens de remarquer que ce cas d'utilisation est explicitement décrit dans la section Drawbacks de la RFC React Hooks ici: github.com/reactjs/rfcs/blob/master/text / ...


En fait, useState ici devrait avoir le deuxième argument de [] , sinon il est appelé à chaque rendu alors qu'il n'est pas nécessaire.



0
votes

useCallback aurait dû fonctionner ici.

import React, { useState, useEffect, useCallback } from "react";
import PubSub from "pubsub-js";
import { Controlled as CodeMirror } from "react-codemirror2";
import "codemirror/lib/codemirror.css";
import EventEmitter from "events";

let ee = new EventEmitter();

const initialValue = "initial value";

function App(props) {
  const [value, setValue] = useState(initialValue);

  // Should get the latest value
  const handler = (msg, data) => {
    console.info("Value in event handler: ", value);
    document.getElementById("result").innerHTML = value;
  };

  const handleEvent = useCallback(handler, [value]);

  // Get value from server on component creation (mocked)
  useEffect(() => {
    setTimeout(() => {
      setValue("value from server");
    }, 1000);
  }, []);

  // Subscribe to events on component creation
  useEffect(() => {
    PubSub.subscribe("some_event", handleEvent);
    return () => {
      PubSub.unsubscribe(handleEvent);
    };
  }, [handleEvent]);
  useEffect(() => {
    ee.on("some_event", handleEvent);
    return () => {
      ee.off(handleEvent);
    };
  }, []);

  return (
    <React.Fragment>
      <CodeMirror
        value={value}
        options={{ lineNumbers: true }}
        onBeforeChange={(editor, data, newValue) => {
          setValue(newValue);
        }}
      />
      <button
        onClick={() => {
          ee.emit("some_event");
        }}
      >
        EventEmitter (works)
      </button>
      <button
        onClick={() => {
          PubSub.publish("some_event");
        }}
      >
        PubSub (doesnt work)
      </button>
      <button
        onClick={() => {
          setTimeout(() => handleEvent(), 100);
        }}
      >
        setTimeout (works!)
      </button>
      <div id="result" />
    </React.Fragment>
  );
}

export default App;

Vérifiez les codesandbox ici https://codesandbox.io/s/react-base-forked-i9ro7


0 commentaires