Über unsMediaKontaktImpressum
Tim Kraut 15. Oktober 2019

React Hooks – Eine Einführung

In React können Komponenten auf zwei Arten geschrieben werden: Als Funktion und als Klasse. Lange Zeit galt die Devise, sobald komponenten-interner State oder Lifecycle-Methoden benötigt werden, muss die Komponente als Klasse geschrieben werden. Seit Version 16.8 gibt es mit den sogenannten Hooks eine wegweisende Neuerung in React, die es ermöglicht, dafür nun funktions-basierte Komponenten zu verwenden. Wie man Hooks einsetzt und welche Vorteile und Herausforderungen damit einhergehen, erläutert dieser Artikel.

Komponenten in React

Ein entscheidender Vorteil von React (dasselbe gilt auch für Frameworks wie Angular oder Vue) ist die deklarative Programmierung. Anstatt Schritt für Schritt (imperativ) vorzugeben, wie sich das DOM verändern soll, gibt man nur noch deklarativ den Zielzustand an und React nimmt die notwendigen Veränderungen am DOM vor.

Während Angular oder Vue HTML um eine Mikrosyntax erweitern um Template-Logik wie Bedingungen oder Schleifen zu ermöglichen, setzt React auf eine Erweiterung von JavaScript um HTML-ähnliche Bestandteile: JSX. Technisch landet dank Babel trotzdem nur gewöhnliches JavaScript im Browser.

Das zentrale Element in React sind Komponenten. Eine React-Anwendung besteht aus mindestens einer Komponente. Die Grundidee hinter einer React-Komponente kann man relativ mathematisch ausdrücken: f(p) = h. Oder einfacher formuliert: Eine Komponente f nimmt Props (p) entgegen und liefert ein Ergebnis h, das sich anfühlt wie HTML, auch wenn es technisch JavaScript ist. Im Falle des ReactDOM-Renderers werden basierend darauf dann die tatsächlichen DOM-Knoten erstellt.

Unabhängig davon, ob die Komponente als Klasse oder Funktion implementiert wurde, ist ihre Verwendung immer dieselbe

<UserList items={users} onUserSelect={handleUserSelection} />

In dem Beispiel ist UserList eine eigene Komponente, die zwei sogenannte Props (vergleichbar mit HTML-Attributen) übergeben bekommt. Die Benutzer, die angezeigt werden sollen, werden über die Prop items übergeben und bei Auswahl eines Benutzers wird der an onUserSelect übergebene Callback aufgerufen. Wird eine Prop verändert, aktualisiert React die Komponente und evaluiert, ob es DOM-Änderungen geben muss. Falls ja, versucht es diese auf möglichst effiziente Art und Weise durchzuführen.

Klassen-basierte Komponenten

Implementiert man eine Komponente als Klasse, stehen sogenannte Lifecycle-Methoden zur Verfügung, mit denen man sich in bestimmte Momente im Lebenszyklus der Komponente einklinken kann. Die wichtigsten Lifecycle-Methoden sind dabei componentDidMount(), componentDidUpdate() und componentWillUnmount().

In diesem zugegebenermaßen etwas konstruierten Beispiel sind zwei Features auf die unterschiedlichen Lifecycle-Methoden aufgeteilt: Verhalten, dass von Props abhängt (in diesem Fall this.props.userId) bzw. Verhalten, bei dem eine Aufräum-Phase sinnvoll ist:

import React, { Component } from 'react'

class ResponsiveUserList extends Component {
  componentDidMount() {
    // Feature 1
    console.log(`Simulieren von Request an /users/${this.props.userId} in componentDidMount()`)

    // Feature 2
    window.addEventListener('resize', this.onResize)
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // Feature 1
    if (prevProps.userId !== this.props.userId) {
      console.log(`Simulieren von Request an /users/${this.props.userId} in componentDidUpdate()`)
    }
  }

  componentWillUnmount() {
    // Feature 2
    window.removeEventListener('resize', this.onResize)
  }

  // Feature 2
  onResize = (event) => {
    console.log(`Aktuelle Breite: ${event.target.innerWidth}`)
  }

  render() {
    return (
      <p>Console in den DevTools öffnen um die Ausgabe zu sehen</p>
    )
  }
}

export default ResponsiveUserList

Probleme mit Klassen-basierten Komponenten

Fehler-anfällig

Wie die Praxis gezeigt hat, wird oft nur componentDidMount() oder componentDidUpdate() verwendet, obwohl beide Methoden erforderlich wären um eine wirklich reaktive Komponente zu erhalten, die sowohl initial (componentDidMount()) als auch nach einem Update der Komponente (componentDidUpdate()) die gewünschte Aktion ausführt. Analog dazu wird gerne componentWillUnmount() zum Aufräumen von Timern, laufenden Netzwerk-Requests etc. vergessen.

Widerspruch zu funktionaler Natur von React-Komponenten

Technisch bedingt waren klassen-basierte Komponente lange Zeit die mächtigeren Komponente, obwohl sie nicht zu der funktionalen Natur von React passen und sich wie ein Fremdkörper anfühlen.

<MyButton
  onClick={onBuyClick}
  title="Kaufen"
/>

Konzeptionell (technisch ist das Ganze etwas komplizierter [1]) entspricht das Ganze dem bereits erwähnten f(p) = h bzw. folgendem Aufruf:

MyButton({
  onClick: onBuyClick,
  title: 'Kaufen'
})

Eine reale Implementierung der <MyButton>-Komponente könnte beispielsweise so aussehen:

function MyButton({ onClick, title }) {
  return (
    <button
      className="button"
      onClick={onClick}
    >
      {title}
    </button>
  )
}

Verletzung des Prinzips der Kohäsion

Ein weiterer Nachteil ist, dass das Prinzip der Kohäsion (zusammengehörender Code sollte auch optisch zusammenstehen) im Falle von klassen-basierten Komponenten oft nicht beachtet werden kann. Anstatt die gesamte Logik für ein bestimmtes Feature (wie z. B. das Umschalten einer Variable, je nachdem, ob die Browser-Breite kleiner oder größer als 600px ist) an einem Ort zu haben, wird diese nach Lifecycle-Methoden gruppiert.

In den Abbildungen 1 und 2 sind die Features aus dem obigen Beispiel der entsprechenden Implementierung mit Hooks gegenübergestellt. Die farblich markierten Flächen stellen ein zusammengehöriges Feature dar. Im Falle der Klasse ist die Implementierung der beiden Features über die gesamte Komponente verteilt, wohingegen das Prinzip der Kohäsion bei Hooks beachtet werden kann.

Wiederverwendbares Verhalten mit State erfordert komplizierte Patterns

Möchte man Daten per GET-Request von einer API laden, ist es oftmals eine gute Idee, während des Ladevorgangs einen Spinner anzuzeigen. Tritt während des Requests ein Fehler auf, sollte eine Fehlermeldung angezeigt werden und im Erfolgsfall die heruntergeladenen Daten. Um diese drei Zustände ("Pending", "Error" und "Success") abzubilden, benötigt man eine Form von State, z. B. komponenten-internen State oder eine externe Bibliothek wie Redux. Möchte man die beschriebene Daten-Download-Logik wiederverwenden, kommt man mit React-Bordmitteln nur mittels relativ komplexer Patterns wie "Higher-Order Components" oder dem "Function as a child"-Pattern ans Ziel.

React Hooks

Mit React 16.8 hat das React-Team im Februar 2019 eine Lösung für die genannten Probleme mit Lifecycle-Methoden bzw. Klassen veröffentlicht: Die sogenannten Hooks. Diese erlauben es, State und Seiten-Effekte auch in funktionsbasierten Komponenten zu verwenden.

Das ist aber mehr das Symptom als das Ziel von Hooks. Interessanter ist, dass wir dank Hooks wiederverwendbare Stateful-Logik ohne komplizierte Patterns erhalten. Und ohne mit den Nachteilen von Mixins wie impliziten Dependencies, Namens-Kollisionen etc. leben zu müssen. Man könnte auch sagen, Hooks erlauben es, das Rendern von UI-Komponenten von der Business-Logik zu trennen.

Wichtig dabei ist, dass Hooks keinen Breaking Change darstellen, sondern aktuell nur eine weitere Variante darstellen, wie Komponenten geschrieben werden können. Das React-Team wird auch nicht müde zu betonen, dass es derzeit keine Pläne gibt, klassenbasierte Komponenten aus dem React-Core zu entfernen. Nichtsdestotrotz ist aber absehbar, dass es eines Tages sehr wahrscheinlich eine React-Version ohne Klassen geben wird. Womöglich werden diese ähnlich wie "PropTypes", die bis Version 15.4 Bestandteil von React selbst waren, in ein separates Package ausgelagert. Da Facebook ca. 90.000 Komponenten im Einsatz hat [2], die sonst alle neu geschrieben werden müssten, ist nicht damit zu rechnen, dass dies allzu schnell passieren wird. Aktuell ist daher die Empfehlung des React-Teams: Hooks ausprobieren und eventuell bei neuen Komponenten verwenden. Ein kompletter Rewrite aller Komponenten ist nicht nötig [3].

useState()-Hook

Wie der Name bereits sagt, wird der useState()-Hook zur Verwaltung von komponenten-internen States verwendet. Anders als bei Klassen wird der State nicht mit dem bisherigen State gemerged, stattdessen werden für die unterschiedlichen Variablen im State üblicherweise auch mehrere useState()-Aufrufe verwendet. Die Syntax für einen einfachen Counter ist dabei z. B. folgende:

const [[counter, setCounter] = useState(0)

Der initiale Wert wird als Funktionsparameter an useState() übergeben. Die etwas ungewohnte Syntax des Rückgabewertes nennt sich "Array Destructuring" und ist analog zu:

const counterState = useState(0)
const counter = counterState[0]
const setCounter = counterState[1]

Einen einfachen Counter könnte man beispielsweise so umsetzen:

import React, { useState } from "react"
import ReactDOM from "react-dom"

function Counter() {
  const [counter, setCounter] = useState(0)

  return (
    <>
      <button onClick={() => setCounter(counter + 1)}>Um 1 erhöhen</button>
      <p>Aktueller Wert: {counter}</p>
    </>
  )
}

 

useEffect()-Hook

Um sogenannte Seiten-Effekte zu implementieren, gibt es den useEffect()-Hook. Seiteneffekte sind im Falle von React alles, was nicht unmittelbar das Rendern von UI-Komponenten betrifft, wie z. B. Console-Ausgaben, Netzwerk-Requests, Event-Handler etc.
Die grundlegende Syntax des useEffect()-Hooks ist dabei folgende:

useEffect(callback, dependencyArray)

Der Callback ist der Seiteneffekt, der ausgeführt werden soll. Das Dependency Array kann verschiedene Werte haben:

  • (kein Wert) = Immer ausführen
  • [] = Einmal bei Komponenten-Start ausführen
  • [a, b] = Immer, wenn sich eine der Variablen a oder b geändert hat (wird per Referenz-Vergleich mittels Object.is() ermittelt), ausführen

Möchte man beispielsweise den <title> einer React-Anwendung dynamisch setzen (ein Seiteneffekt), so könnte das folgendermaßen aussehen:

function Page({ title }) {
  useEffect(() => {
    document.title = title
  }, [title])
 
  return (
    <>...</>
  )
}

Diese Logik lässt sich auch sehr einfach in einen sogenannten Custom-Hook, also einen eigenen, wiederverwendbaren Hook auslagern:

import { useEffect } from 'react'

export function useTitle(title) {
  useEffect(() => {
    document.title = title
  }, [title])
}

Das folgende Beispiel zeigt die beiden Besonderheiten beim Verwenden von useEffect():

import React, { useEffect, useState } from 'react'

function App() {
  const [width, setWidth] = useState(window.innerWidth)
  const [counter, setCounter] = useState(0)

  function onResize(event) {
    setWidth(event.target.innerWidth)
  }

  useEffect(() => {
    console.count('useEffect: addEventListener')

    // An dieser Stelle geht es nur darum zu zeigen, dass die Aufräum-Funktion getriggert wird. Die Implementierung ist hier nicht relevant
    if (width > 500) { /* ... */ }

    window.addEventListener('resize', onResize)

    return () => {
      console.count('useEffect: removeEventListener')

      window.removeEventListener('resize', onResize)
    }
  }, [width])

  console.count('render')

  return (
    <>
      <p>
        Hinweis: Browser-Fenster verkleinern/vergrößern um Veränderungen in der
        Breite zu sehen. Über den Button kann ein Neurendern erzwungen werden
      </p>

      {/* Der `counter` wird nur zum Erzwingen eines Rerenderings benötigt */}
      <button onClick={() => setCounter(counter + 1)}>Neurendern erzwingen</button>

      <h1>Breite: {width} Pixel</h1>
    </>
  )
}

Der Effect wird erst nach dem initialen Rendern ausgeführt. Das führt dazu, dass die eigentliche Komponente möglichst schnell angezeigt wird. Muss ein Effect doch einmal vor dem initialen Rendern ausgeführt werden, gibt es den useLayoutEffect()-Hook. In den allermeisten Fällen ist das aber nicht nötig. Führt man den obigen Code im Browser aus, kann man anhand der Konsolenausgabe das Verhalten des useEffect()-Hooks nachvollziehen.

Außerdem wird die Logik des Seiteneffekts nur ausgeführt, wenn sich eine der angegebenen Dependencies geändert hat. Das ist wichtig um z. B. Event-Handler nur einmal hinzuzufügen oder Daten nicht bei jedem Komponenten-Update erneut herunterzuladen. Im Falle von z. B. Event-Handlern kann eine Funktion zurückgegeben werden, die zum "Aufräumen" aufgerufen wird. Das passiert technisch unmittelbar bevor der Effekt ein weiteres Mal ausgeführt wird (s. Browser-Console).

Vorteile von Hooks

In Kombination mit dem useState()-Hook kann man so die Logik, die bisher auf die drei Lifecycle-Methoden componentDidMount(), componentDidUpdate() und componentWillUnmount() verteilt war, auch in funktionsbasierten Komponenten verwenden. Zusätzlich erhält man den enormen Vorteil der einfachen Wiederverwendbarkeit von Stateful-Logik und das Prinzip der Kohäsion kann sehr leicht gewahrt werden. Das obere Beispiel mit den beiden Features könnte mit Hooks folgendermaßen implementiert werden:

const ResponsiveUserList = ({ userId }) => {
  useEffect(() => {
    console.log(`Simulieren von Request an /users/${userId} in useEffect()`)
  }, [userId])

  useEffect(() => {
    window.addEventListener('resize', onResize)

    return () => window.removeEventListener('resize', onResize)
  }, [])

  function onResize(event) {
    console.log(`Aktuelle Breite: ${event.target.innerWidth}`)
  }

  return (
    <p>Console in den DevTools öffnen um die Ausgabe zu sehen</p>
  )
}

export default ResponsiveUserList

 

Rules of Hooks & ESLint-Plugin-React-Hooks

Beim Verwenden von Hooks muss man die sogenannten "Rules of Hooks" [4] beachten. Hooks dürfen nur innerhalb von Komponenten oder Custom Hooks aufgerufen werden. Das heißt insbesondere nicht innerhalb von Bedingungen, Schleifen oder verschachtelten Funktionen, da React intern den Index der Hooks-Aufrufe verwendet, um diese den jeweiligen Komponenten-Instanzen zuzuordnen. Shawn Wang hat auf der JSConf.Asia 2019 dazu einen sehr interessanten Vortrag gehalten [5].

Damit der Einsatz von Hooks leichter fällt und man nicht versehentlich die "Rules of Hooks" verletzt oder im Dependency Array Variablen vergisst bzw. überflüssige verwendet, gibt es ein sehr gelungenes ESLint-Plugin des React-Teams, das auf Problemstellen hinweist und eine sehr gut funktionierende Autofix-Funktionalität mitbringt. Verwendet man Create-React-App in der aktuellen Version, ist dieses Plugin bereits miteingebaut.

Weitere Hooks

Neben den bereits angesprochenen Hooks useState(), useEffect() und useLayoutEffect() gibt es noch einige weitere:

  • useContext(): zur einfachen Verwendung der Context-API von React (mit dem Hook lässt sich der Context Consumer eleganter implementieren).
  • useReducer(): um mehrere State-Änderungen zusammenzufassen analog zu Reducern, wie man sie vielleicht von Redux kennt.
  • useCallback() und useMemo(): um memoizierte Variablen bzw. Funktionen zu erstellen. Das kann für die Performance-Optimierung interessant sein. Außerdem lassen sich so in bestimmten Fällen Endlosschleifen verhindern.
  • useRef(): gibt Zugriff auf DOM-Elemente mittels der React Refs um z. B. den Fokus manuell zu setzen. Außerdem kann useRef() als Ersatz für Instanz-Variablen von Klassen-Komponenten dienen [6].
  • useImperativeHandle(): Dürften die wenigsten im Alltag benutzen, da dieser Hook für imperative Code-Ausführung gedacht ist. Für Library-Autoren könnte dieser Hook aber interessant sein.
  • useDebugValue(): Erlaubt die Ausgabe von Werten in den React DevTools.

Verbleibende Lifecycle-Methoden

Derzeit gibt es zwei Lifecycle-Methoden, die man noch nicht durch Hooks (oder ein anderes Feature in React) ersetzen kann: getSnapshotBeforeUpdate() und componentDidCatch(). Im Falle von componentDidCatch() bietet sich der Einsatz einer Higher-Order-Component an [7], sodass man auch in diesem Fall auf Klassen-Komponenten für die eigentliche Anwendung verzichten kann.

Stale-Closures-Problem

Hooks lösen alte Probleme, schaffen dafür aber auch neue Herausforderungen. Eine davon ist das Stale-Closures-Problem. Aufgrund der Art und Weise, wie Closures funktionieren, kann es sein, dass eine Variable unerwarteterweise nicht dem aktualisierten Wert entspricht. Hilfreich ist dabei auf jeden Fall der Einsatz des oben erwähnten ESLint-Plugins, das viele Probleme direkt beheben kann. Daneben gibt es die Möglichkeit, der Funktion zum Setzen des States auch eine Funktion mitzugeben. Anstelle von setCounter(counter + 1) kann man so setCounter(counter => counter + 1) schreiben und dadurch sichergehen, dass counter immer den aktuellen Wert hat.

Testen mit Enzyme

Lange Zeit war Enzyme in Kombination mit Jest das Mittel der Wahl zum Testen von React-Komponenten [8]. Reibungslos funktionieren Hooks und Enzyme nach aktuellem Stand allerdings nicht. Es gibt ein Github Issue, das den aktuellen Stand der Enzyme-Implementierung zusammenfasst [9]. Problemlos funktioniert das Testen von Komponenten mit Hooks dafür mit der Bibliothek React Testing Library [10] (in Kombination mit Jest), die auch unabhängig davon mehr als einen Blick wert ist, da sie einige der Problemstellen von Enzyme elegant umgeht.

Fazit

React Hooks sind eine grundlegende Neuerung in der Entwicklung von React-Applikationen. Auch andere Frameworks wie Vue hat die Idee hinter Hooks überzeugt, sodass sich in Zukunft voraussichtlich auch dort vergleichbare Ansätze finden werden. Der große Vorteil von Hooks ist es, Logik, die State verwendet, in wiederverwendbare Funktionen packen zu können. Das erlaubt nicht nur die Trennung von Layout und Verhalten, sondern ermöglicht es auch, verhältnismäßig einfachen Code zu schreiben, der zudem noch gut testbar ist. Die Kehrseite der Medaille ist, dass Hooks neue Herausforderungen wie Stale Closures schaffen und für viele erst einmal ungewohnt sein dürften. Lifecycle-Methoden sind möglicherweise für den einen oder anderen intuitiver und vertrauter. Die Zukunft von React dürfte klar in Richtung Hooks gehen, auch wenn vermutlich noch viele Jahre ins Land gehen werden, bis Klassen-Komponenten kein Bestandteil von React mehr sind.

Autor

Tim Kraut

Tim Kraut hat Informatik in Deutschland und Frankreich studiert und arbeitet derzeit als Webentwickler für AWESOME! Software in Köln. Er ist ein Freund von Open Source-Projekten, bekennender Firefox-Fan und immer erfreut über eine...
>> Weiterlesen
botMessage_toctoc_comments_9210