Umgang mit veralteten Props und Zuständen in Reacts Funktionskomponenten

Avatar of Pedro Rodriguez
Pedro Rodriguez am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Es gibt einen Aspekt von JavaScript, der mich immer wieder zur Verzweiflung treibt: Closures. Ich arbeite viel mit React, und die Überschneidung besteht darin, dass sie manchmal die Ursache für veraltete Props und Zustände sein können. Wir werden uns damit beschäftigen, was das genau bedeutet, aber das Problem ist, dass die Daten, die wir zum Erstellen unserer UI verwenden, auf unerwartete Weise völlig falsch sein können, was, wissen Sie, schlecht ist.

Veraltete Props und Zustände

Kurz gesagt: Es ist, wenn Code, der asynchron ausgeführt wird, eine Referenz auf eine Prop oder einen Zustand hat, der nicht mehr *frisch* ist, und somit der Wert, den er zurückgibt, nicht der aktuellste ist.

Um es noch klarer zu machen, spielen wir mit dem gleichen Beispiel für veraltete Referenzen, das React in seiner Dokumentation hat.

function Counter() {
  const [count, setCount] = useState(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

(Live-Demo)

Nichts Besonderes hier. Wir haben eine Funktionskomponente namens Counter. Sie zählt, wie oft der Benutzer auf eine Schaltfläche geklickt hat, und zeigt eine Benachrichtigung an, die anzeigt, wie oft diese Schaltfläche geklickt wurde, wenn auf eine andere Schaltfläche geklickt wird. Probieren Sie das aus.

  1. Klicken Sie auf die Schaltfläche „Klick mich“. Sie werden sehen, wie der Klickzähler hochzählt.
  2. Klicken Sie nun auf die Schaltfläche „Alarm anzeigen“. Drei Sekunden sollten vergehen, und dann wird ein Alarm ausgelöst, der Ihnen mitteilt, wie oft Sie auf die Schaltfläche „Klick mich“ geklickt haben.
  3. Klicken Sie nun erneut auf die Schaltfläche „Alarm anzeigen“ und klicken Sie schnell auf die Schaltfläche „Klick mich“, bevor der Alarm in drei Sekunden ausgelöst wird.

Sehen Sie, was passiert? Die auf der Seite angezeigte Anzahl und die im Alarm angezeigte Anzahl stimmen nicht überein. Die Zahl im Alarm ist jedoch keine zufällige Zahl. Diese Zahl ist der Wert, den die Variable count zum Zeitpunkt der Definition der asynchronen Funktion innerhalb von setTimeout hatte, was der Zeitpunkt ist, an dem die Schaltfläche „Alarm anzeigen“ geklickt wird.

So funktionieren Closures nun mal. Wir werden uns in diesem Beitrag nicht auf die Einzelheiten eingehen, aber hier sind einige Dokumente, die sie detaillierter behandeln.

Konzentrieren wir uns darauf, wie wir diese veralteten Referenzen mit unseren Zuständen und Props vermeiden können.

React gibt in derselben Dokumentation, aus der das Beispiel entnommen wurde, einen Tipp, wie man mit veralteten Daten und Props umgeht.

Wenn Sie absichtlich den neuesten Zustand aus einem asynchronen Callback lesen möchten, könnten Sie ihn in einer ref speichern, ihn mutieren und daraus lesen.

Indem wir den Wert *asynchron* in einer ref speichern, können wir veraltete Referenzen umgehen. Wenn Sie mehr über ref in Funktionskomponenten erfahren müssen, bietet die Dokumentation von React viele weitere Informationen.

Das wirft also die Frage auf: Wie können wir unsere Props oder Zustände in einer ref speichern?

Machen wir es zuerst auf die unsaubere Art.

Die unsaubere Art, Props und Zustände in einer Ref zu speichern

Wir können eine Ref einfach mit useRef() erstellen und count als Anfangswert verwenden. Dann setzen wir überall dort, wo der Zustand aktualisiert wird, die Eigenschaft ref.current auf den neuen Wert. Schließlich verwenden wir ref.current anstelle von count im asynchronen Teil unseres Codes.

function Counter() {
  const [count, setCount] = useState(0);
  const ref = useRef(count); // Make a ref and give it the count

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + ref.current); // Use ref instead of count
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button
        onClick={() => {
          setCount(count + 1);
          ref.current = count + 1; // Update ref whenever the count changes
        }}
      >
        Click me
      </button>
      <button
        onClick={() => {
          handleAlertClick();
        }}
      >
        Show alert
      </button>
    </div>
  );
}

(Live-Demo)

Machen Sie dasselbe wie beim letzten Mal. Klicken Sie auf „Alarm anzeigen“ und dann auf „Klick mich“, bevor der Alarm in drei Sekunden ausgelöst wird.

Jetzt haben wir den neuesten Wert!

Das ist der Grund, warum es funktioniert. Wenn die asynchrone Callback-Funktion innerhalb von setTimeout definiert wird, speichert sie eine Referenz auf die von ihr verwendeten Variablen, in diesem Fall count. Auf diese Weise ändert React beim Aktualisieren des Zustands nicht nur den Wert, sondern die Variablenreferenz im Speicher ist auch völlig anders.

Das bedeutet, dass – selbst wenn der Wert des Zustands nicht primitiv ist – die Variable, mit der Sie in Ihrem asynchronen Callback arbeiten, nicht dieselbe im Speicher ist. Ein Objekt, das normalerweise seine Referenz über verschiedene Funktionen hinweg beibehält, hat jetzt einen anderen Wert.

Wie löst die Verwendung einer ref dieses Problem? Wenn wir uns die React-Dokumentation noch einmal ansehen, finden wir eine interessante, aber leicht zu übersehende Information:

[…] useRef gibt Ihnen bei jedem Render dieselbe ref-Objekt zurück.

Es spielt keine Rolle, was wir tun. Während der gesamten Lebensdauer Ihrer Komponente gibt uns React dasselbe Ref-Objekt im Speicher zurück. Jeder Callback, egal wann er definiert oder ausgeführt wird, arbeitet mit demselben Objekt. Keine veraltete Referenz mehr.

Die sauberere Art, Props und Zustände in einer Ref zu speichern

Seien wir ehrlich... eine ref auf diese Weise zu verwenden, ist eine hässliche Lösung. Was passiert, wenn Ihr Zustand an tausend verschiedenen Stellen aktualisiert wird? Jetzt müssen Sie Ihren Code ändern und die ref manuell an all diesen Stellen aktualisieren. Das ist ein No-Go.

Wir werden dies skalierbarer machen, indem wir der ref automatisch den Wert des Zustands geben, wenn sich der Zustand ändert.

Beginnen wir damit, die manuelle Änderung der ref in der Schaltfläche „Klick mich“ zu entfernen.

Als Nächstes erstellen wir eine Funktion namens updateState, die aufgerufen wird, wenn wir den Zustand ändern müssen. Diese Funktion nimmt den neuen Zustand als Argument, setzt die Eigenschaft ref.current auf den neuen Zustand und aktualisiert gleichzeitig den Zustand mit demselben Wert.

Schließlich ersetzen wir die ursprüngliche setCount-Funktion, die React uns zur Verfügung stellt, durch die neue updateState-Funktion, wo der Zustand aktualisiert wird.

function Counter() {
  const [count, setCount] = useState(0);
  const ref = useRef(count);

  // Keeps the state and ref equal
  function updateState(newState) {
    ref.current = newState;
    setCount(newState);
  }

  function handleAlertClick() { ... }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button
        onClick={() => {
          // Use the created function instead of the manual update
          updateState(count + 1);
        }}
      >
        Click me
      </button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

(Live-Demo)

Verwendung eines benutzerdefinierten Hooks

Die sauberere Lösung funktioniert gut. Sie erledigt die Aufgabe genauso wie die unsaubere Lösung, ruft aber nur eine einzige Funktion auf, um den Zustand und die ref zu aktualisieren.

Aber wissen Sie was? Wir können es noch besser machen. Was ist, wenn wir weitere Zustände hinzufügen müssen? Was ist, wenn wir das auch in anderen Komponenten tun wollen? Nehmen wir den Zustand, die ref und die Funktion updateState und machen sie wirklich portabel. Benutzerdefinierte Hooks zur Rettung!

Außerhalb der Counter-Komponente definieren wir eine neue Funktion. Nennen wir sie useAsyncReference. (Sie kann eigentlich beliebig benannt werden, aber beachten Sie, dass es üblich ist, benutzerdefinierte Hooks mit dem Präfix „use“ zu benennen.) Unser neuer Hook hat vorerst einen einzigen Parameter. Wir nennen ihn value.

Unsere vorherige Lösung hatte dieselbe Information doppelt gespeichert: einmal im Zustand und einmal in der ref. Wir optimieren das, indem wir den Wert dieses Mal nur in der ref speichern. Mit anderen Worten, wir erstellen eine ref und geben ihr den Parameter value als Anfangswert.

Direkt nach der ref erstellen wir eine Funktion updateState, die den neuen Zustand nimmt und ihn der Eigenschaft ref.current zuweist.

Schließlich geben wir ein Array mit ref und der Funktion updateState zurück, sehr ähnlich zu dem, was React mit useState macht.

function useAsyncReference(value) {
  const ref = useRef(value);

  function updateState(newState) {
    ref.current = newState;
  }

  return [ref, updateState];
}

function Counter() { ... }

Wir vergessen etwas! Wenn wir die Dokumentation zu useRef prüfen, erfahren wir, dass das Aktualisieren einer ref keinen Re-Render auslöst. Solange ref den aktualisierten Wert hat, sehen wir die Änderungen nicht auf dem Bildschirm. Wir müssen jedes Mal einen Re-Render erzwingen, wenn ref aktualisiert wird.

Was wir brauchen, ist ein Fake-State. Der Wert spielt keine Rolle. Er dient nur dazu, den Re-Render auszulösen. Wir können sogar den Zustand ignorieren und nur seine Update-Funktion behalten. Wir nennen diese Update-Funktion forceRender und geben ihr den Anfangswert false.

Nun erzwingen wir innerhalb von updateState den Re-Render, indem wir forceRender aufrufen und ihm einen anderen Zustand als den aktuellen übergeben, nachdem wir ref.current auf newState gesetzt haben.

function useAsyncReference(value) {
  const ref = useRef(value);
  const [, forceRender] = useState(false);

  function updateState(newState) {
    ref.current = newState;
    forceRender(s => !s);
  }

  return [ref, updateState];
}

function Counter() { ... }

Nehmen Sie, welchen Wert er auch immer hat, und geben Sie das Gegenteil zurück. Der Zustand spielt keine wirkliche Rolle. Wir ändern ihn nur, damit React eine Zustandsänderung erkennt und die Komponente neu rendert.

Als Nächstes können wir die Count-Komponente bereinigen und die zuvor verwendete useState, ref und updateState-Funktion entfernen, dann den neuen Hook implementieren. Der erste Wert des zurückgegebenen Arrays ist der Zustand in Form einer ref. Wir werden ihn weiterhin count nennen, wobei der zweite Wert die Funktion zum Aktualisieren des Zustands/der ref ist. Wir werden ihn weiterhin setCount nennen.

Wir müssen auch die Referenzen auf den Zähler ändern, da diese nun alle count.current sein müssen. Und wir müssen setCount aufrufen, anstatt updateState aufzurufen.

function useAsyncReference(value) { ... }

function Counter() {
  const [count, setCount] = useAsyncReference(0);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count.current);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button
        onClick={() => {
          setCount(count.current + 1);
        }}
      >
        Click me
      </button>
      <button onClick={handleAlertClick}>Show alert</button>
    </div>
  );
}

Dies mit Props funktionsfähig machen

Wir haben eine wirklich portable Lösung für unser Problem. Aber wissen Sie was... es gibt noch etwas mehr zu tun. Insbesondere müssen wir die Lösung mit Props kompatibel machen.

Nehmen wir die Schaltfläche „Alarm anzeigen“ und die Funktion handleAlertClick in eine neue Komponente außerhalb der Counter-Komponente. Wir nennen sie Alert und sie erhält eine einzige Prop namens count. Diese neue Komponente zeigt den count-Prop-Wert, den wir ihr übergeben, nach einer Verzögerung von drei Sekunden in einem Alarm an.

function useAsyncReference(value) { ... }

function Alert({ count }) {
  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + count);
    }, 3000);
  }

  return <button onClick={handleAlertClick}>Show alert</button>;
}

function Counter() { ... }

In Counter ersetzen wir die Schaltfläche „Alarm anzeigen“ durch die Alert-Komponente. Wir übergeben count.current an die count-Prop.

function useAsyncReference(value) { ... }

function Alert({ count }) { ... }

function Counter() {
  const [count, setCount] = useAsyncReference(0);

  return (
    <div>
      <p>You clicked {count.current} times</p>
      <button
        onClick={() => {
          setCount(count.current + 1);
        }}
      >
        Click me
      </button>
      <Alert count={count.current} />
    </div>
  );
}

(Live-Demo)

In Ordnung, Zeit, die Testschritte erneut durchzugehen. Sehen Sie? Auch wenn wir eine sichere Referenz auf den Zähler in Counter verwenden, ist die Referenz auf die count-Prop in der Alert-Komponente nicht asynchron sicher und unser benutzerdefinierter Hook ist noch nicht für die Verwendung mit Props geeignet.

Glücklicherweise ist die Lösung recht einfach.

Alles, was wir tun müssen, ist, einen zweiten Parameter zu unserem useAsyncReference-Hook namens isProp hinzuzufügen, mit false als Anfangswert. Kurz bevor wir das Array mit ref und updateState zurückgeben, richten wir eine Bedingung ein. Wenn isProp true ist, setzen wir die Eigenschaft ref.current auf value und geben nur ref zurück.

function useAsyncReference(value, isProp = false) {
  const ref = useRef(value);
  const [, forceRender] = useState(false);

  function updateState(newState) {
    ref.current = newState;
    forceRender(s => !s);
  }

  if (isProp) {
    ref.current = value;
    return ref;
  }

  return [ref, updateState];
}

function Alert({ count }) { ... }

function Counter() { ... }

Aktualisieren wir nun Alert so, dass es den Hook verwendet. Denken Sie daran, true als zweiten Argument an useAsyncReference zu übergeben, da wir eine Prop und keine State übergeben.

function useAsyncReference(value) { ... }

function Alert({ count }) {
  const asyncCount = useAsyncReference(count, true);

  function handleAlertClick() {
    setTimeout(() => {
      alert("You clicked on: " + asyncCount.current);
    }, 3000);
  }

  return <button onClick={handleAlertClick}>Show alert</button>;
}

function Counter() { ... }

(Live-Demo)

Versuchen Sie es noch einmal. Jetzt funktioniert es perfekt, egal ob Sie Zustände oder Props verwenden.

Noch eine Sache…

Es gibt eine letzte Änderung, die ich vornehmen möchte. Die Dokumentation von Reacts useState besagt, dass React auf einen Re-Render verzichtet, wenn der neue Zustand mit dem vorherigen identisch ist. Unsere Lösung tut das nicht. Wenn wir den aktuellen Zustand erneut an die updateState-Funktion des Hooks übergeben, erzwingen wir unabhängig davon einen Re-Render. Ändern wir das.

Packen wir den Körper von updateState in eine if-Anweisung und führen wir ihn aus, wenn ref.current sich vom neuen Zustand unterscheidet. Der Vergleich muss mit Object.is() erfolgen, genau wie React es tut.

function useAsyncReference(value, isProp = false) {
  const ref = useRef(value);
  const [, forceRender] = useState(false);

  function updateState(newState) {
    if (!Object.is(ref.current, newState)) {
      ref.current = newState;
      forceRender(s => !s);
    }
  }

  if (isProp) {
    ref.current = value;
    return ref;
  }

  return [ref, updateState];
}

function Alert({ count }) { ... }

function Counter() { ... }

Jetzt sind wir endlich fertig!


React kann manchmal wie eine Blackbox erscheinen, die voller kleiner Eigenheiten ist. Diese Eigenheiten können einschüchternd sein, wie die, die wir gerade behandelt haben. Aber wenn Sie geduldig sind und Herausforderungen mögen, werden Sie bald feststellen, dass es ein großartiges Framework und eine Freude ist, damit zu arbeiten.