Autor
Felix Schaffernicht
Teamleader Software Development
bei SYZYGY Techsolutions
Lesedauer
7 Minuten
Publiziert
24. Januar 2023
React Hooks sind in der Benutzung meistens straight forward, aber wenn man eigene Hooks schreiben will durchaus etwas tricky. Hier möchte ich auf ein Beispiel eingehen, welches so oder so ähnlich im Internet oft zu finden ist, jedoch für Verwirrung sorgen kann, möchte man wirklich verstehen, wie ein Hook genau funktioniert.
Performante Custom Hooks schreiben
React Hooks sind mittlerweile etabliert, und nicht mehr aus der React Welt wegzudenken. Mit ihnen hat sich so manche Library, wie zum Beispiel Redux, React Router oder Apollo Client in der Benutzung sehr verändert. Zum Positiven, wie ich finde. Siehst du dir folgendes Beispiel an, muss man eigentlich gar nicht mehr viele Worte verlieren. Wir wollen es trotzdem kurz reflektieren.
Bindung von React-Redux an eine HOC (Higher Order Component).
import { connect } from "react-redux";
function App({ children, message, changeMessage }) {
return (
<div>
<h1>{message}</h1>
<button onClick={() => changeMessage("New message")}>
Change message
</button>
</div>
);
}
const mapStateToProps = (state) => ({
message: state.message,
});
const mapDispatchToProps = (dispatch) => ({
changeMessage: (value) =>
dispatch({ type: "CHANGE_MESSAGE", payload: value }),
});
const withState = connect(mapStateToProps, mapDispatchToProps);
export default withState(App);
In der Komponente passiert nicht viel. Es wird eine Message ausgegeben und auf einem Button liegt ein onClick Event, welches changeMessage aufruft. Dann müssen wir mit connect, mapStateToProps und mapDispatchToProps unsere Komponente mit State anreichen.
Code kurz mal lesen und sacken lassen. Um der guten alten Zeiten willen.
Und jetzt ein Beispiel, wie man es mit den Hooks von React-Redux machen kann.
import { useSelector, useDispatch } from "react-redux";
function App({ children }) {
const message = useSelector((state) => state.message);
const dispatch = useDispatch();
return (
<div>
<h1>{message}</h1>
<button
onClick={() =>
dispatch({ type: "CHANGE_MESSAGE", payload: "New message" })
}
>
Change message
</button>
</div>
);
}
export default App;
Hier importieren wir uns einfach useSelector und useDispatch von react-redux, um diese dann in unserer Komponente zu benutzen. Der Funktion useSelector müssen wir nur mitteilen, was wir vom State bekommen möchten. Die Funktion useDispatch wird ohne Argumente aufgerufen und kann in der Benutzung später ein Objekt mit einem type und wahlweise einen beliebigen payload empfangen.
What a difference!
Vergleichen wir diesen Code mit dem Beispiel zuvor, finde ich, wird der Unterschied in der Lesbarkeit und der Benutzung klar. Schon bei so einem kleinen Beispiel.
In der echten Welt wird es noch drastischer, wenn man mit weiteren Libraries wie z.B. React Apollo arbeitet. Dort musste man früher auch noch eine HOC verwenden und ich weiß noch, wie ich mit Kolleg:innen da stand und wir rumrätselten, welcher Funktionsaufruf denn als nächstes käme. Mal ehrlich…
War das einfacher?
const gqlPage = compose(WithData)(Page);
const WithStore = connect(mapStateToProps, mapDispatchToProps);
const WithForm = reduxForm({
form: FILTER_FORM_NAME,
});
export default WithStore(WithForm(gqlPage));
Und jetzt bitte nochmal schnell erklären, was in welcher Reihenfolge aufgerufen wird. Bitte. Danke.
Ok, hooks sind nice… und nu?
Wie sieht es aber aus, wenn du eigene Hooks schreiben möchtest? Nun, ich kann hier nur von mir sprechen, aber ich habe, wenn ich mich im Internet nach Custom Hooks umsah, schnell Beispiele gefunden, bei denen ich mich relativ schwergetan habe, diese nachzuvollziehen. Ich möchte dir eines dieser Beispiele nun zeigen und demystifizieren.
Schreiben wir einen Hook
Wir möchten auf Native Events im DOM hören und wollen dafür einen Custom Hook useEventListener schreiben, welcher in der Benutzung so aussehen könnte.
useEventListener("mousemove", (event) => console.log(event));
Ziemlich straight forward. useEventListener ist unser Hook, den wir irgendwo in einer React Komponente – nicht konditional natürlich 🙂 – aufrufen. Das erste Argument ist unser Event, auf das wir hören möchten. Das zweite Argument ist die Funktion in der wir unsere Businesslogik behandeln.
In der ersten Version könnten wir den Custom Hook so schreiben.
import { useEffect } from "react";
// NOT FINAL!
export default function useEventListener(event, callback) {
useEffect(() => {
function handler() {
callback();
}
document.addEventListener(event, handler);
return () => {
document.removeEventListener(event, handler);
};
}, [callback]);
}
Hier deklarieren wir unsere Funktion, welche event und callback übergeben bekommt. Dann fügen wir in einem useEffect unseren Custom Event Listener mit der Funktion hinzu, welche unseren Callback aufruft. Dann räumen wir beim unmounten noch auf und entfernen unseren Custom Event Listener. Als letztes müssen wir noch den Callback in unser Dependency Array mit aufnehmen, damit wir immer den letzten Callback mitbekommen und aktuell bleiben.
Der Code liest sich leichter als die Beschreibung, ich weiß. Allerdings stimmt etwas nicht mit diesem Hook. useEffect wird jetzt potentiell immer wieder neu aufgerufen, wenn der Callback aufgerufen wird. Das liegt daran das callback im Dependency Array ist.
}, [callback]);
Das heißt, bei jedem Aufruf unserer Business Logik in unserer Komponente wird im Hintergrund auch der useEffect neu aufgerufen und der Event Listener zuerst aufgeräumt, also entfernt und dann wieder neu hinzugefügt. Das ist wahrscheinlich nicht was wir wollen.
Und jetzt richtig
import { useEffect, useRef } from "react";
export default function useEventListener(event, callback) {
const latestCallback = useRef();
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
useEffect(() => {
function handler() {
latestCallback.current();
}
document.addEventListener(event, handler);
return () => {
document.removeEventListener(event, handler);
};
}, []);
}
Wenn du dich fragst, warum wir nun 2 useEffects haben, stehst du nicht alleine da. Genau das habe ich mich auch gefragt. Gehen wir den Code einfach Stück für Stück durch.
import { useEffect, useRef } from "react";
export default function useEventListener(event, callback) {
Zuerst importieren wir uns useEffect und useRef. Dann deklarieren wir unsere Funktion, welche mit den Argumenten event und callback beschrieben ist.
const latestCallback = useRef();
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
Hier speichern wir uns eine Referenz in latestCallback und benutzen dafür useRef. In dieser Referenz speichern wir uns in einem useEffect immer den letzten und damit aktuellsten Callback. Daher auch das gefüllte Dependecy Array hier und nicht im zweiten useEffect.
[callback];
So können wir im zweiten useEffect, wo wir unsere eigentliche Logik behandeln, unserem Handler immer den aktuellsten Callback – quasi heimlich – übergeben, da dieser auf die Referenz von latestCallback verweist, ohne diese im Dependecy Array mitgeben zu müssen. So wird dieser Effect nur beim mounten und unmounten ausgeführt. Bäm!
useEffect(() => {
function handler() {
latestCallback.current();
}
document.addEventListener(event, handler);
return () => {
document.removeEventListener(event, handler);
};
}, []);
Du siehst hier, dass unser Dependency Array leer ist. Statt callback direkt aufzurufen, rufen wir jetzt latestCallback.current auf. Fertig.
Fazit
Custom Hooks zu schreiben muss nicht schwer sein, kann aber etwas tricky werden, wenn du es richtig machen willst. Ich würde dir trotzdem empfehlen, Custom Hooks immer dann zu schreiben, wenn du die Möglichkeit dazu hast. Denn es ist eine hervorragende Möglichkeit, die Feinheiten von React.js zu lernen und Business Logik auszulagern. Wenn du deine Custom Hooks wiederverwenden kannst, wirst du es dir selbst danken, sie geschrieben zu haben.
Head of Technology