alle Artikel
JavaScript & TypeScript
React
7 Minuten

Referential Equality - Ein Konzept, das React clever zu nutzen weiß!

„ Lerne Referential Equality im Zusammenspiel mit nicht-primitiven Datentypen in React kennen. Zwei Probleme, drei Auswirkungen und viele Lösungen! ”
Referential Equality - Ein Konzept, das React clever zu nutzen weiß!

Referential Equality ist ein einfaches und dennoch sehr wichtiges Thema, weil es einige Probleme verursachen kann, aber auch clevere Lösungen ermöglicht. Besonders in React ist es von großer Bedeutung, um zu verstehen, wie React funktioniert und wie nicht. Dazu gehört auch zu verstehen, wie JavaScript funktioniert; denn Referential Equality ist kein React-Feature, sondern ein Konzept von JavaScript. Dieses Konzept ist für einige Schlüsselkonzepte von React wichtig. Deswegen wirst du hier einiges lernen!

Über JavaScript lernst du in diesem Artikel:

  • Was Referential Equality ist und wie sie angewendet wird.
  • Wie sich primitive und nicht-primitive Datentypen unterscheiden.
  • Was Immutability ist und wie sie angewendet wird.

Über React lernst du in diesem Artikel unter anderem:

  • Was Referential Equality und Immutability mit Statemanagement zu tun hat.
  • Welche Bedeutung Referential Equality bei der Verwendung der Dependencies Arrays von Hooks hat.
  • Welche Bedeutung Referential Equality bei der Verwendung von React.memo() hat.

Die meisten Programmiersprachen vergleichen Datentypen entweder über ihre Referenz oder ihren Wert. JavaScript verfolgt ein hybrides Konzept und vergleicht primitive Datentypen wie String, Number und Boolean über ihren Wert und nicht-primitive Datentypen wie Objekte, Arrays und Funktionen über ihre Referenz. Eine Referenz ist ein Zeiger auf eine bestimmte Stelle im Arbeitsspeicher, sozusagen eine Speicheradresse. Referential Equality vergleicht also nur die Speicheradressen, nicht die gespeicherten Werte. Die Unterschiede zwischen primitiven und nicht-primitiven Datentypen sind von entscheidender Bedeutung. Du wirst gleich verstehen, warum!

Primitive Datentypen wie String, Number und Boolean werden über ihre Werte verglichen; daher gilt das Folgende:

true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true

Der Vergleich von Werten liefert hier keine unerwarteten Erkenntnisse.

Wenn wir uns jedoch den Vergleich über die Referenz anschauen, sieht das schon anders aus:

{} === {} // false
[] === [] // false
(() => {}) === (() => {}) // false
const x = {}
x === x // true

Gleich bedeutet hier eben nicht gleiche Werte, sondern gleiche Referenz. Auch wenn sie ansonsten gleich sind, verschiedene Objekte, Arrays oder Funktionen haben in JavaScript verschiedene Speicheradressen. Ohne gleiche Speicheradresse gibt es keine Referential Equality.

Das folgende Beispiel kann jeder selbst nachstellen und zeigt Referential Equality am Beispiel eines Objekts.

const obj1 = { fruit: "apple" };
const obj2 = { fruit: "apple" };
const obj3 = obj1;

console.log("obj1 === obj1", obj1 === obj1); // true
console.log("obj1 === obj2", obj1 === obj2); // false
console.log("obj2 === obj3", obj2 === obj3); // false
console.log("obj1 === obj3", obj1 === obj3); // true

Warum der Inhalt nicht-primitiver Datentypen nicht auf Gleichheit geprüft wird, ist naheliegend. Diese Prüfung wäre deutlich rechenintensiver und komplizierter als ein Vergleich der Speicheradressen. Im Englischen wird für den Vergleich der Speicheradressen oft der Begriff “shallow comparison” (oberflächlicher Vergleich) verwendet und für den Vergleich von Werten “deep comparison” (tiefer Vergleich). Eine Prüfung auf Referential Equality ist also einfach und sehr performant, weil nur oberflächlich auf Speicheradressen geprüft wird und nicht tiefgehend auf Werte, was viel aufwendiger ist. In der Praxis verwendet React für den Vergleich der Referential Equality eine eigene Funktion mit dem Namen ShallowEqual. ShallowEqual verwendet Object.is() für den Vergleich der Speicheradressen. Object.is() ist dem Strict Equality Check mit === sehr ähnlich, für Demonstrationszwecke jedoch weniger gut lesbar. Deswegen verwende ich in den Beispielen den Strict Equality Operator ===. Die Prüfung auf Referential Equality kann also mit Object.is() und dem Strict Equality Operator === erfolgen.

In React bringt Referential Equality neben seiner Einfachheit und seiner hohen Performance jedoch auch zwei wesentliche Probleme mit. Diese zwei Probleme sind maßgeblich für den gesamten weiteren Verlauf dieses Artikels und damit für das Verständnis von React.

Die zwei Probleme mit der Referential Equality in React

Problem 1: Objekte und Arrays und ihre Mutability (Veränderbarkeit)

Primitive Datentypen wie String, Number und Boolean sind in JavaScript grundsätzlich immutable, also unveränderlich. Nicht-primitive Datentypen wie Objekt, Array und Funktion sind in JavaScript von Natur aus mutable, also veränderbar. Warum ist das im Falle von Objekten so wichtig?

Schaue dir das folgende Beispiel an:

const stateObj = { fruit: 'apple' };
stateObj.fruit = "banana"

console.log(stateObj.fruit) // banana

Im Beispiel ändern wir das Objekt direkt, denn wir wissen bereits, nicht-primitive Datentypen sind in JavaScript direkt veränderbar. Daraus ergibt sich allerdings ein Problem mit der Referential Equality! Obwohl wir die Frucht von Apfel auf Banane geändert haben, ist die Referential Equality gleich, weil auch die Speicheradresse gleich geblieben ist. Direkte Änderungen an Objekten und Arrays ändern die Speicheradresse nicht!

Problem 2: Nicht-primitive Datentypen und ihre Initialisierung

Das zweite Problem mit der Referential Equality tritt im Zusammenhang mit der Initialisierung von nicht-primitiven Datentypen auf. Funktionen, Objekte und Arrays bekommen in Javascript bei jeder Initialisierung eine neue Speicheradresse zugewiesen. Die Initialisierung erfolgt mit der Definition und nicht mit der Verwendung dieser Datentypen. Schau dir dieses Beispiel an:

function example() {

  function fruitLover() {
      console.log("I love fruits!");
  }

  const obj = { fruit: "apple"}

  const arr = ["banana", "peach"]

}

example()
example()
example()

Im Beispiel wurden die drei nicht-primitiven Datentypen Funktion, Objekt und Array innerhalb einer Funktion example() definiert, aber nicht verwendet. Dass dies technisch keinen Sinn ergibt, ist klar, soll uns aber nicht weiter stören, weil es allein um die Definition dieser Datentypen geht. Die Funktion example() wird dreimal aufgerufen, was dazu führt, dass alle drei nicht-primitiven Datentypen mit jedem neuen Aufruf eine neue Speicheradresse zugewiesen bekommen. Das bedeutet im Kontext zu React: Funktionen, Objekte und Arrays werden in React bei jedem Re-render einer Komponente neu initialisiert und bekommen eine neue Speicheradresse zugewiesen. Die Referential Equality ist dadurch nicht mehr gegeben, auch wenn die Daten nicht verändert wurden. Dieser Punkt ist sehr wichtig und muss bei der Arbeit mit React unbedingt beachtet werden!

Zusammenfassung des Kapitels

React nutzt die Vorteile der Referential Equality, muss dafür jedoch zwei Probleme lösen.

  1. Erstens die Tatsache, dass nicht-primitive Datentypen im JavaScript direkt veränderbar (mutable) sind. Referential Equality erkennt jedoch keine direkten Veränderungen, weil dabei die Speicheradresse gleich bleibt.
  2. Zweitens den Umstand, dass nicht-primitive Datentypen im JavaScript mit jeder Initialisierung eine neue Speicheradresse zugewiesen bekommen, obwohl die Werte gleich bleiben. Referential Equality erkennt dadurch Änderungen, auch wenn es keine gibt.

Wo diese beiden Probleme in React auftreten und wie sie gelöst werden können, davon handelt der Rest dieses Artikels.

Zu Problem 1: Die Mutability von Objekten und Arrays und das Auslösen von Re-renders

Auswirkung: Statemanagement und Re-Rendering

Re-rendering ist eines der wichtigsten Konzepte in React und jeder Re-render startet mit einem State-Update irgendwo im Komponentenbaum. Ein State-Update wird ausgelöst, um Änderungen im User-Interface vorzunehmen. Durch das State-Update wird die Komponente, welche das State-Update auslöst, sowie alle Kindkomponenten ihres Stammbaums mit allen Änderungen neu gerendert. Um Re-renderings von Kindkomponenten zu verhindern, gibt es mehrere Techniken, eine davon ist Memoisierung mit React.memo(). React.memo() ist eine Higher-Order Komponente und prüft die Properties ihrer Komponente vor einem Re-render mit Object.is() auf Referential Equality. Erkennt Object.is() eine Änderung der Properties wird neu gerendert, ansonsten nicht. Dann und nur dann werden Properties auf Änderungen geprüft! Es ist ein weit verbreiteter Mythos, dass Änderungen an Properties Re-renders auslösen; doch dazu müssten Properties immer geprüft werden. Welchen Sinn sollte eine solche Prüfung haben, wenn ohnehin neu-gerendert wird, sobald die Elternkomponente neu rendert? React macht es deswegen genau andersherum und prüft die Properties nur, wenn der Entwickler eine Notwendigkeit dafür sieht und deshalb React.memo() verwendet. Mehr dazu gibt es im Kapitel “Referential Equality und React.memo()”. Der Vollständigkeit halber gibt es auch noch die Methode, forceUpdate() um ein Re-Render zu erzwingen, das ist allerdings fast nie sinnvoll.

Doch zurück zum State, wo uns die Referential Equality und Object.is() gleich wieder begegnen werden. State ist in React ein immutable Objekt, es kann also theoretisch nicht verändert werden. Dabei spielt es keine Rolle, ob der State über useState, useReducer, useContext oder Redux verwaltet wird. Warum ist das so?

Um diese Frage zu beantworten, zeige ich nochmal das Beispiel von oben:

const stateObj = { fruit: 'apple' };
stateObj.fruit = "banana" // State Anti-Pattern!!!

console.log(stateObj.fruit) // banana

Das Objekt wird direkt geändert, die Speicheradresse bleibt deshalb gleich. React vergleicht Alten und Neuen State mit Object.is() auf Speicheradressen, weshalb die Änderung nicht erkannt und kein Re-render durchgeführt wird. Direkte Änderungen von State-Objekten lösen also keine Re-Render aus. Was ist die Lösung für dieses Problem?

Lösung: Referential Equality kombiniert mit Immutability

Die Lösung lautet Immutability! Das Konzept der Immutability stammt aus der funktionalen Programmierung und ist für das Statemanagement in React von zentraler Bedeutung. Wer bereits mit useState(), useReducer() oder Redux gearbeitet hat, der wird mit der nun folgenden Verwendung von Object.assign() und Spread-Operator ... vertraut sein. Beide erstellen eine Kopie des geänderten Objektes, ohne das bestehende Objekt zu ändern.

// Object example
let obj = { fruit: 'apple' }

// Object mutability
obj.fruit = "cherry"
console.log(obj.fruit)   // cherry => same memory address

// Object immutability before ES6 with Object.assign()
obj = Object.assign(obj, {fruit: "banana"}) 
console.log(obj.fruit)   // banana => different memory address

// Object immutability since ES6 with Spread-Operator
obj = {...obj, fruit: 'peach'} 
console.log(obj.fruit)   // peach => different memory address


// Array example
let arr = ["apple"]

// Array mutability
arr.push("peach")   // ["apple", "peach"] => same memory address

// Array immutability with Spread-Operator
arr = [...arr, "peach"]   // ["apple", "peach"] => different memory address

Das sind einfache Beispiele für das Konzept der Immutability. Immutability löst hier das Problem mit der Referential Equality. Wir ändern nicht das bestehende Objekt, sondern erstellen eine geänderte Kopie. Diese Kopie hat eine andere Speicheradresse, wodurch JavaScript bzw. React über den Vergleich der Speicheradressen mit Object.is() erkennt, dass sich das Objekt geändert hat.

Würde man bestehende Objekte ändern, müsste bei einer Prüfung auf Gleichheit jeder einzelne Wert und seine Position im Objekt oder Array geprüft werden. Das wäre nicht effektiv, weil zu rechenintensiv und zu komplex, insbesondere bei tief verschachtelten Objekten. Referential Equality in Kombination mit Immutability ist für nicht-primitive Datentypen dagegen eine effektive Lösung, die sowohl einfach als auch performant ist. Das macht React hier sehr clever, um maximale Performance zu erreichen!

Dass nicht-primitive Datentypen in JavaScript direkt veränderbar sind, kann leider auch React nicht ändern; deswegen habe ich zu Beginn dieses Kapitels geschrieben, dass State in React ein “theoretisch” unveränderbares Objekt ist. In der Praxis kann das State-Objekt sehr wohl direkt geändert werden, jedoch erkennt React diese Änderung nicht, wodurch der tatsächliche State nicht geändert wird. Das ist ein Anti-Pattern und führt zu Fehlern! Hier ein Unterlassungsbeispiel:

export const StateWithoutImmutability = () => {
  const [fruitObj, setFruitObj] = useState({ fruit: "apple" });

  const handleClick = () => {
    fruitObj.fruit = fruitObj.fruit === "banana" ? "apple" : "banana"; // don't do this to change State!!!
    setFruitObj(fruitObj);
  };

  console.log("Does it re-render after the button is clicked?"); // nope!!!

  return (
    <div>
      <button onClick={handleClick}>
        Click me and try to change the State
      </button>
      <p>
        Current fruit: {fruitObj.fruit}
      <p>
    </div>
  );
};

Und hier die Lösung für korrektes Re-rendern durch State-Updates mit Immutability, umgesetzt mit dem Spread-Operator ... :

export const StateWithImmutability = () => {
  const [fruitObj, setFruitObj] = useState({ fruit: "apple" });

  const handleClick = () => {
    setFruitObj({
      ...fruitObj,
      fruit: fruitObj.fruit === "banana" ? "apple" : "banana",
    });
  };

  console.log("Does it re-render after the button is clicked?"); // yes!!!

  return (
    <div>
      <button onClick={handleClick}>
        Click me and try to change the State
      </button>
      <p>
        Current fruit: {fruitObj.fruit}
      <p>
    </div>
  );
};

Da der Umgang mit dem Spread-Operator bei komplexeren State-Objekten schnell unübersichtlich wird, empfehle ich das Tool immer.js. Das macht den Umgang mit Immutability deutlich einfacher und übersichtlicher. Die Statemanagement-Library Redux Toolkit nutzt immer.js deswegen in seinen Funktionen createReducer() und createSlice() automatisch. Mehr zum Thema Immutability findest du im Artikel Immutability in JavaScript – Explained with Examples von Deborah Kurata.

Zusammenfassung des Kapitels

  • Immutability sorgt für eine neue Speicheradresse des geänderten States. Der Referential Equality Check mittels Object.is() erkennt den geänderten State anhand seiner neuen Speicheradresse, nicht anhand seines neuen Wertes.
  • Die Kombination von Referential Equality und Immutability bildet das Grundkonzept für effektives Statemanagement in React. Es ist eine clevere Lösung, weil sie einfach und performant ist.
  • immer.js macht den Umgang mit Immutability bei komplexen Objekten einfacher und wird auch im Redux Toolkit verwendet.

Zu Problem 2: Nicht-primitive Datentypen und ihre Reinitialisierung nach Re-renders

Auswirkung: Dependencies Arrays von React Hooks

Neben Re-Renderings durch Änderungen am State zählt der Lifecycle von React-Komponenten zu den wichtigsten Konzepten in React. Abgesehen von componentDidCatch(), getDerivedStateFromError() und getSnapshotBeforeUpdate() , ersetzen Hooks alle Lifecycle-Methoden die wir aus React-Klassen kennen, und machen dadurch vieles einfacher. Voraussetzung dafür ist, du kannst damit umgehen, was du spätestens am Ende dieses Artikels können wirst!

Referential Equality bezieht sich auf die Dependencies Arrays von Hooks und ist somit für alle Hooks wichtig, die ein Dependencies Array verwenden. Auch hier verwendet React wieder Object.is(), um Änderungen in Dependencies Arrays festzustellen.

Bevor wir tiefer in das Problem der Referential Equality und Hooks eintauchen, ist es sinnvoll, uns nochmal die Grundlagen von Hooks vor Augen zuführen.

Das Wichtigste über Hooks

  • Hooks sind normale JavaScript-Funktionen, die zu einem bestimmten Zeitpunkt im Lifecycle einer Komponente aufgerufen werden. Dieser Zeitpunkt im Lifecycle unterscheidet sich je nach Hook.
  • Hooks fangen mit immer mit dem Prefix “use” an, wie z.B. useEffect, useRef, useCustomHookName.
  • Hooks können nur auf der obersten Ebene einer funktionalen React-Komponente verwendet werden, nicht in React-Klassen und auch nicht innerhalb von Bedingungen wie zum Beispiel if-Clauses.
  • Einige Hooks akzeptieren als ersten Funktionsparameter eine Callback-Funktion und als zweiten Parameter ein Dependencies Array.
  • Jeder Wert und jede Funktion, die innerhalb der Callback-Funktion verwendet wird, muss im Dependencies Array enthalten sein.
  • Das Dependencies-Array regelt, wann die an den Hook übergebene Callback-Funktion aufgerufen wird. Dabei gelten die folgenden Regeln:
    • Ohne Dependencies Array wird die Callback-Funktion des Hooks bei jedem Re-render der Komponente aufgerufen.
    • Mit einem leeren Dependencies Array wird die Callback-Funktion nur beim ersten Rendern aufgerufen.
    • Enthält das Dependencies Array Werte oder Funktionen, wird die Callback-Funktion beim ersten Rendern aufgerufen und wenn sich mindestens ein Wert oder eine Speicheradresse im Array ändert.

Der letzte Satz dieser Auffrischung über React Hooks enthält die entscheidende Information. Die Callback-Funktion in einem Hook wird dann aufgerufen, wenn sich eine Abhängigkeit (Dependency) im Array ändert, sei es ein geänderter Wert oder eine geänderte Referenz auf eine Speicheradresse. Im Zusammenhang mit der Referential Equality spielen für uns nur die geänderten Speicheradressen nicht-primitiver Datentypen eine Rolle, denn wie oben bereits angesprochen, werden Funktionen, Objekte und Arrays in React bei jedem Rendern neu initialisiert und bekommen dabei eine neue Speicheradresse, auch wenn ihr Inhalt nicht verändert wurde. Das führt zu unbeabsichtigten Aufrufen der Callback-Funktionen von Hooks! Mehr über Hooks findest du in den Rules of Hooks der React-Dokumentation.

Die Reinitialisierung nicht-primitiver Datentypen triggert bei Re-Renders unbeabsichtigt Hooks

Jetzt heißt es aufpassen, denn es ist sehr wichtig für die Arbeit mit Hooks! Ich zeige dir zwei Beispiele. Im ersten Beispiel bekommt eine Komponente das Property someDataType von ihrer Elternkomponente. Dieses Property wird als Dependency im useEffect Hook verwendet. Du kannst den Datentyp dieses Property selbst auswählen und über die Konsole sehen, ob der Hook getriggert wird, wenn du auf einen der beiden Buttons klickst oder nicht.

Beispiel 1 - Unterschiedliche Datentypen über Properties:

Im zweiten Beispiel kommt der Datentyp someDataType direkt von einem Custom-Hook und wird in derselben Komponente als Dependency im useEffect Hook verwendet, wo auch der Custom-Hook verwendet wird. Auch hier kannst du wieder den Datentyp auswählen und über die Konsole beobachten, was passiert, wenn du auf den Button klickst.

Beispiel 2 - Unterschiedliche Datentypen über Custom-Hooks:

Das Ergebnis: Egal wo nicht-primitive Datentypen reinitialisiert werden, sei es in einer Elternkomponente, einem Custom-Hook oder in der Komponente selbst, die Auswirkung der geänderten Speicheradressen ist die gleiche. Das gilt auch für nicht-primitive Datentypen aus Third-Party-Libraries. In der Praxis hast du deswegen oftmals keine Kontrolle darüber, wann nicht-primitive Datentypen reinitialisiert werden. Dann hättest du auch kaum Kontrolle darüber, wann der Hook bzw. seine Callback-Funktion aufgerufen wird. Ich nutze hier bewusst den Konjunktiv, denn natürlich bietet React auch für diese Fälle Lösungen an und selbst mit JavaScript kann dieses Problem in bestimmten Fällen gelöst werden. In den meisten Fällen sind die negativen Auswirkungen des Problems jedoch nicht wahrnehmbar und deshalb sind auch keine Gegenmaßnahmen erforderlich. Allerdings kann dieses Problem auch zu Infinite-Loops, Infinite-Fetches oder gravierenden Performance-Problemen führen, was Lösungen unausweichlich macht.

Doch bevor wir zu den Lösungen kommen, zeige ich dir noch eine weitere Auswirkung von Problem 2; denn gleiches Problem bedeutet hier auch gleiche Lösungen.

Auswirkung: Referential Equality und Properties bei React.memo() Komponenten

Das Problem, dass nicht-primitive Datentypen bei jedem Re-render neu initialisiert werden und dadurch eine neue Speicheradresse bekommen, besteht auch bei React.memo(). Die Aufgabe von React.memo() ist zu verhindern, dass eine Komponente re-rendert, wenn ihre Elternkomponente re-rendert. Nur wenn eine Prüfung der Properties mit Object.is() eine Änderung anzeigt, wird neu gerendert. Nicht-primitive Datentypen werden also wieder nur auf Referential Equality, d.h. auf geänderte Speicheradressen geprüft. Zu welchen Problemen das führen kann, sehen wir jetzt.

Die Reinitialisierung nicht-primitiver Datentypen hebelt bei Re-renders React.memo() aus

Es ist das gleiche Problem wie in den Dependencies Arrays von Hooks, nur an anderer Stelle und mit anderen Auswirkungen. Diese Auswirkungen sind leider nicht weniger, als dass React.memo() dadurch nicht mehr funktioniert. Schauen wir uns dazu das folgende Beispiel an und probieren es mit echtem Code aus:

Im Codebeispiel siehst du, wie schnell es passiert, dass React.memo() nicht mehr funktioniert. Es braucht nur einen einzigen nicht-primitiven Datentyp als Property, der reinitialisiert wurde, und schlimmer noch, es gibt noch weitere Konstellationen, die React.memo() wirkungslos machen. Doch darüber schreibe ich in meinem nächsten Artikel, denn React.memo() richtig zu verwenden kann ganz schön tricky sein! Hier kommen wir jetzt zu den Lösungen und die sind prinzipiell die Gleichen, egal ob es um das Problem mit den Hooks oder um das mit React.memo() geht; denn schließlich ist ja auch die Ursache die Gleiche.

Lösungen: Zum Beispiel Destructing, useMemo() oder useCallback()

Wenn wir über Lösungen nachdenken, sollten wir auch überlegen, ob eine Lösung überhaupt erforderlich ist. Für das Problem mit den Dependencies Arrays von Hooks stellt sich deshalb immer die Frage nach den Kosten und dem Nutzen der Lösung. Für das Problem mit React.memo() stellt sich die Frage, ob eine Memoisierung der Komponente überhaupt notwendig ist und erst dann die Frage, welche Lösung die beste ist und welche Lösung überhaupt funktioniert. Auch auf diese wichtigen Fragen gehe ich in meinem nächsten Artikel ein, denn sie sind nicht Thema dieses Artikels und die Meinungen zu diesen Fragen gehen weit auseinander. Hier gehe ich nur auf die wichtigsten Lösungsansätze ein.

Wichtig ist dabei auch die Frage, wann ein Problem zum Problem wird; wenn wir Entwickler es auf unseren High End Computern feststellen oder wenn es ein Anwender mit langsamem Endgerät und langsamer Internetverbindung spürt? Die Frage ist natürlich rhetorisch und soll darauf aufmerksam machen, dass die Sachlage nicht immer so eindeutig ist, wie sie erscheint. In der Webentwicklung muss immer beachtet werden, dass es auch Nutzer mit langsamen Endgeräten und langsamer Internetverbindung geben kann und meistens auch gibt. Die folgenden Lösungen sollten deswegen mit Bedacht eingesetzt werden, erstens mit Bedacht auf die Kosten und Nutzen, mit Bedacht auf richtige Verwendung und mit Bedacht auf langsame Endgeräte. Im Zweifelsfall hilft Testen und Messen!

Lösungen bei Objekten und Arrays

  1. Vermeide nicht-primitive Datentypen als Dependencies und Properties allgemein und ganz besonders wenn sie von anderen Komponenten, Custom Hooks oder Third-Party-Libraries kommen.
  2. Wenn möglich, verwenden nur die wirklich notwendigen Daten aus Objekten und Arrays. Extrahiere sie z.B. mit Destructing und übergebe sie als primitive Datentypen.
  3. Sofern notwendig, zerlege einfache Objekte und Arrays über Destructing in ihre Einzelteile, übergebe sie als primitive Datentypen und füge sie bei Bedarf danach wieder zusammen.
  4. Prüfe die Tipps aus der React Dokumentation.
  5. Ansonsten verwende für Objekte und Arrays useMemo() und akzeptiere den geringen Ressourcenverbrauch.

Lösungen bei Funktionen

  1. Sofern möglich, definiere Funktionen außerhalb von Komponenten.
  2. Prüfe die Tipps aus der React Dokumentation.
  3. Ansonsten verwende für Funktionen useCallback() und akzeptiere den geringen Ressourcenverbrauch.

Die Lösungen haben keinen Anspruch auf Vollständigkeit; dennoch decken sie die wichtigsten Punkte und Prinzipien ab. Doch vor allem möchte ich mit ihrer Reihenfolge eines deutlich machen: Die beste Lösung ist nicht immer useMemo() oder useCallback(). Die beste Lösung ist es, sich möglichst allen Lösungsoptionen bewusst zu sein und dann die beste Lösungsoption zu wählen. Einfachheit, Lesbarkeit, Wartbarkeit, schnelle Umsetzbarkeit, Performance und Ressourcenschonung sind wichtige Kriterien auf der Suche nach der besten Lösung. Timeboxing ist eine gute Methode der Analyse-Paralyse-Falle zu entgehen und sich nicht in der Lösungssuche zu verlieren. Setzte dir bei Bedarf für die Lösungssuche ein sinnvolles Zeitlimit und bedenke dabei, die negativen Auswirkungen von useMemo() und useCallback() sind gering, dementsprechend sollte auch das Zeitlimit für das Prüfen von Alternativen gering sein.

Glücklicherweise sind die Alternativen meistens einfach und schnell umsetzbar wie du gleich sehen wirst, denn natürlich bekommst du auch für die Lösungen wieder konkrete Beispiele mit denen du experimentieren kannst. Aus Gründen der Übersichtlichkeit trenne ich die Beispiele für Hooks und React.memo(), auch wenn die Lösungen identisch sind. In den Beispielen kannst du die Fehler nochmals nachstellen und verschiedene Lösungen auswählen und anwenden. Hier mit React.memo():

Und hier am Beispiel der Dependencies Arrays von Hooks:

Records und Tuples

In Zukunft wird es mit Records für Objekte und Tuples für Arrays vielleicht zwei sehr elegante Lösungen direkt über JavaScript geben. Records sind dann unveränderbare Objekte und Tuples unveränderbare Arrays. Object.is() würde dann beide über ihre Werte und nicht über ihre Referenz vergleichen. Der Vorschlag für dieses Record & Tuple Feature befindet sich leider immer noch in Stage2. Die Chancen, dass es kommen wird, stehen gut, sicher ist es allerdings nicht. Die Verwendung wird dann vermutlich so aussehen, mit einer dem Objekt, bzw. dem Array vorangestellten Raute:

const record = #{a: 1, b: 2};

const tuple = #[1, 5, 2, 3, 4];

Ein Tutorial über die mögliche Verwendung von Records und Tuples in einer zukünftigen Version von JavaScript findest du hier.

Zusammenfassung des Kapitels

  • Die Reinitialisierung nicht-primitiver Datentypen bei Re-Renderings hat zwei negative Auswirkungen, die von Bedeutung sind:
    • Im Kontext zu den Dependencies Arrays von Hooks triggert es ungewollt Hooks. Das ist meistens unproblematisch, kann aber auch gravierende Probleme verursachen.
    • Im Kontext zu den Properties von mit React.memo() memoisierten Komponenten funktioniert die Memoisierung nicht. Das ist immer problematisch, wenngleich die Auswirkungen nicht immer gravierend sind.
  • Beiden Auswirkungen liegt dasselbe Problem zugrunde, deswegen sind auch die Lösungen weitgehend gleich.
  • Im Kontext zu Hooks sind Lösungen aber nicht immer erforderlich und immer eine Frage von Kosten und Nutzen.
  • Auch die Verwendung von React.memo() ist eine Frage von Kosten und Nutzen, aber auch immer eine Frage der richtigen Verwendung.
  • Lösungen für Objekte und Arrays
    • Destructing zu primitiven Datentypen
    • useMemo() verwenden
  • Lösungen für Funktionen
    • Funktionsdefinition außerhalb von Komponenten
    • useCallback() verwenden
  • Die Lösungen useMemo() und useCallback() verursachen zusätzlichen Ressourcenverbrauch

Zusammenfassung

Ich hoffe, dieser Artikel war aufschlussreich und du konntest etwas lernen. Obwohl das Prinzip der Referential Equality eigentlich recht einfach ist, sind seine Verwendung und die damit einhergehenden Probleme und Lösungen deutlich komplexer. Falls du vor lauter Informationen den Fokus verloren hast, hier nochmal das Wichtigste zusammengefasst:

  • JavaScript vergleicht primitive Datentypen wie String, Number und Boolean über ihren Wert und nicht-primitive Datentypen wie Objekte, Arrays und Funktionen über ihre Referenz, also über ihre Speicheradresse.
  • Primitive Datentypen sind in JavaScript grundsätzlich immutable, also unveränderlich. Nicht-primitive Datentypen sind in JavaScript von Natur aus mutable, also veränderbar.
  • Referential Equality über Object.is() ist einfach und sehr performant. Er ist dem Strict Equality Check mit === sehr ähnlich.
  • Referential Equality und nicht-primitive Datentypen verursachen zwei Probleme:
    • Problem 1: Nicht-primitive Datentypen sind mutable. Werden sie direkt geändert, erkennt Referential Equality diese Änderung nicht, da die Speicheradresse gleich bleibt.
    • Problem 2: Nicht-primitive Datentypen werden bei jedem Aufruf ihrer Definition neu initialisiert und bekommen eine neue Speicheradresse. Referential Equality betrachtet dies als Änderung, obwohl die Inhalte der Datentypen nicht geändert wurden.
  • Problem 1 tritt in React im Zusammenhang mit Statemanagement und Re-rendering auf und wird durch die Verwendung von Immutability gelöst. Referential Equality und Immutability sind in Kombination eine effektive Lösung und daher essenziell für Statemanagement und Re-rendering in React.
  • Problem 2 tritt bei der Reinitialisierung von Objekten, Arrays und Funktionen durch Re-renderings auf, wenn diese in Dependencies Arrays von Hooks verwendet werden. Die Callback-Funktion des Hooks wird ausgelöst, obwohl sich die Werte im Dependencies Array nicht geändert haben, sondern nur die Speicheradressen.
  • Problem 2 tritt auch bei den Properties von Komponenten auf, die mit React.memo() memoisiert sind. Das kann dazu führen, dass React.memo() nicht funktioniert, weil Re-Rendert wird, obwohl sich die Werte der Properties nicht geändert haben, sondern auch hier nur die Speicheradressen.
  • Problem 2 kann mit Bedacht über useMemo() bei Objekten und Arrays sowie über useCallback() bei Funktionen gelöst werden. Beide sorgen dafür, dass bei Re-renders die Speicheradressen gleich bleiben. Funktionen können auch außerhalb von Komponenten definiert werden, um das Problem zu lösen.
  • Problem 2 kann auch über Destructing von Objekten und Arrays gelöst werden, indem man einzelne Werte aus nicht-primitiven Datentypen extrahiert und als primitive Datentypen übergibt. Denn bei primitiven Datentypen besteht das Problem nicht. Einfache Objekte und Arrays können auch zerlegt, übergeben und danach bei Bedarf wieder zusammengefügt werden.