Erstellen Sie einen useState für Spielzeuge, um Snapshots, Warteschlangen und Effects zu verstehen
Erfahren Sie, wie React-Hooks funktionieren, indem Sie einen kleinen useState erstellen, und nutzen Sie dieses Modell anschließend, um Update-Queues, veraltete Snapshots, Closures, Effekt-Reinigung, Refs und Batching zu erklären.
Quellen: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work
Eine einzige Zeile Hook-Code verbirgt eine überraschend große Menge an Mechanismen:
const [count, setCount] = useState(0);
React muss entscheiden, wo count gespeichert wird, wie es über jeden Aufruf hinaus existiert, welchen Zustand ein bestimmter Setter beeinflusst, warum Hook-Aufrufe nicht bedingungslos sein können und wie Effects Abhängigkeiten verfolgen. Diese Anleitung erstellt ein kleines, funktionsfähiges Modell von Hooks und verwendet es, um die Verhaltensweisen zu erklären, die Menschen täglich in Verwirrung bringen: veraltete Werte nach setState, funktionale Updates, Effekt-Reinigung und Batching.
Zustand existiert nicht in der Funktion
Nehmen wir das kleinste mögliche statebasierte Komponente:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Es ist verlockend zu glauben, dass count innerhalb von Counter gespeichert ist, aber React ruft die Funktion bei jeder Renderung einfach erneut auf:
Render 1
Counter()
count = 0
Render 2
Counter()
count = 1
Render 3
Counter()
count = 2
Lokalen Variablen verschwinden, wenn eine Funktion zurückgegeben wird. Deshalb speichert React den Zustand der Hooks außerhalb des Components, verbunden mit seinem Fiber, und gibt den gespeicherten Wert bei jeder Renderung zurück.
Fiber enthält eine Abfolge von Hooks
In vereinfachter Form sieht die Beziehung so aus:
Component
│
▼
Fiber
│
▼
Hook data
│
┌──┼───────────────┐
▼ ▼ ▼
State Ref Effect
Ein Fiber ist eine Arbeitseinheit von React, die sowohl für Components als auch für Host-Elemente erstellt wird. Für einen Funktionscomponent enthält er die Hook-Daten, konzeptionell als Kette:
Counter Fiber
│
▼
Hook #1
│
▼
Hook #2
│
▼
Hook #3
Die tatsächliche Implementierung weist viele weitere Felder auf, aber dieses Bild reicht aus, um das Kernverhalten zu verstehen.
Hooks werden nach Reihenfolge der Aufrufe abgerufen
Hier ist ein Component mit drei Hooks:
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
useEffect(() => {
console.log(count);
}, [count]);
return ...;
}
React verfügt über keine Namen für diese Hooks; er kennt nur den ersten, zweiten und dritten und geht davon aus, dass sie bei jeder Renderung in dieser Reihenfolge auftauchen:
Fiber
│
├── Hook #1 → useState
│
├── Hook #2 → useState
│
└── Hook #3 → useEffect
Diese Annahme bildet die gesamte Grundlage der Regeln für Hooks.
Was geht bei einem bedingten Hook schief
Betrachten wir nun einen Hook, der hinter einer Bedingung versteckt ist:
function Component({ loggedIn }) {
if (loggedIn) {
useState("Salim"); // ❌
}
useState(0);
return ...;
}
Bei einer Darstellung, bei der der Benutzer eingeloggt ist, werden zwei Hooks aufgerufen:
loggedIn = true
Hook #1 → name
Hook #2 → count
Falls loggedIn später auf false gesetzt wird, findet nur ein Aufruf statt:
loggedIn = false
Hook #1 → count
Der Platz, der ursprünglich den Namen speichern sollte, erhält nun den Aufruf zur Zählung – dadurch wird der Zustand mit dem falschen Hook verknüpft:
Before:
1 → name
2 → count
After:
1 → count
Daher darf die Reihenfolge der Hooks bei jeder Darstellung niemals variieren, was Aufrufe an solchen Stellen ausschließt:
if / else
loops
nested functions
event handlers
Platzieren Sie sie auf der obersten Ebene von Komponenten und benutzerdefinierten Hooks; das React hooks Lint-Plugin sorgt dafür.
Ein einfaches useState in zwanzig Zeilen
Durch den Aufbau einer minimalen Version wird der Mechanismus konkret. Der untenstehende Entwurf beinhaltet ein Array mit Anschlussstellen sowie einen Cursor. Jeder Aufruf von useState beansprucht die Anschlussstelle am Cursor, initialisiert sie nur, wenn sie leer ist, gibt einen Setter zurück, der an diesen Index gebunden ist, und verschiebt den Cursor vorwärts:
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const index = currentHook;
if (hooks[index] === undefined) {
hooks[index] = {
state: initialValue
};
}
function setState(value) {
hooks[index].state =
typeof value === "function"
? value(hooks[index].state)
: value;
render();
}
const state = hooks[index].state;
currentHook++;
return [state, setState];
}
Zwei Dinge werden implizit angenommen, aber nicht gezeigt: render() muss currentHook auf 0 zurücksetzen, bevor der Komponentenaufruf erneut erfolgt, und er muss die Komponente erneut ausführen. Angenommen, es handelt sich um eine solche Komponente:
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
return ...;
}
läuft die Speicherstruktur am Ende wie folgt aus:
hooks[0]
state = 0
hooks[1]
state = "Salim"
Beim nächsten Render findet jeder Aufruf seine Anschlussstelle erneut ausschließlich anhand der Position.
Wo das vereinfachte Modell endet
Real React fügt Hook-Objekte zu Fibers hinzu, Update-Warteschlangen, einen Scheduler mit Prioritätskanälen, Mount- und Update-Dispatcher sowie Logik für Abgleich und Commit; es gibt kein globales Array. Die grundlegende Idee bleibt unverändert:
Stable Hook order
+
React-managed Hook state
+
Component Fiber
Wie useState in React funktioniert
Für diesen Aufruf:
const [count, setCount] = useState(0);
React benötigt für jeden State-Hook zwei Dinge:
State
+
Update queue
In bildlicher Darstellung speichert der Hook seinen aktuellen Wert in memoizedState zusammen mit einer Warteschlange für ausstehende Updates:
Hook
├── memoizedState → 0
│
└── queue
└── pending updates
Die erste Renderung: Mount
Gegeben ist diese Komponente:
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
Die anfängliche Renderung erstellt den Hook, speichert den Anfangswert, legt eine leere Warteschlange an und gibt das Paar zurück:
Component renders
↓
useState(0)
↓
Create Hook
↓
Store initial state
↓
Create update queue
↓
Return [0, setCount]
Der Fiber sieht nun ungefähr so aus, und diese Phase wird als Mount bezeichnet:
Fiber
│
└── Hook
├── state: 0
└── queue
Spätere Renderungen: Update
Nehmen wir an, ein Event-Handler ruft auf:
setCount(5);
React plant einen Update. Während des nächsten Renderings erstellt useState nichts; es findet den vorhandenen Hook, wendet die in der Warteschlange befindlichen Updates an und gibt das Ergebnis zurück:
Component renders
↓
useState(0)
↓
React finds existing Hook
↓
Process queued updates
↓
New state = 5
↓
Return [5, setCount]
Der 0, den Sie übergeben, ist daher ein Anfangswert und keine Rücksetzung. Das spielt nur eine Rolle, wenn der Hook zum ersten Mal erstellt wird.
setState ist eine Anfrage, keine Zuweisung
Eine solche Aufrufart:
setCount(5);
bedeutet nicht „Setze count jetzt auf 5“. Es wird eine Anfrage in die Warteschlange eingereiht, die React zu seinem eigenen Zeitplan bearbeitet:
setCount(5)
↓
Create update
↓
Add update to queue
↓
Schedule React work
↓
Render component
↓
Process update queue
↓
Calculate new state
↓
Reconcile UI
↓
Commit changes
Updates werden in der Reihenfolge abgewickelt
Betrachten Sie drei aufeinanderfolgende funktionale Updates:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
React behält sie in der Warteschlange bei, ausgehend vom aktuellen Wert:
Update Queue
c => c + 1
c => c + 1
c => c + 1
If the current state is: 0
und wendet sie nacheinander an:
0 → 1
1 → 2
2 → 3
Das Ergebnis ist 3, weshalb funktionale Aktualisierungen für Aktualisierungen geeignet sind, die vom vorherigen Wert abhängen.
Warum das Übergeben eines Wertes sich anders verhält
Vergleichen Sie die Version, die Werte übergeben:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Falls der aktuelle Render einen count-Wert von 0 hat, werden alle drei Ausdrücke vor der Verarbeitung jeder Aktualisierung ausgewertet, sodass sie folgendes Ergebnis liefern:
setCount(1)
setCount(1)
setCount(1)
Der endgültige Zustand ist 1. Mit Aktualisierungsfunktionen:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
jede Funktion erhält das Ergebnis der vorherigen, was zu den Werten 0, 1, 2 und schließlich 3 führt.
Zustand ist ein Schnappschuss eines Renders
Dieser Handler überrascht viele Entwickler:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
Das Protokoll gibt 0 aus, nicht 1. count ist eine Konstante, die zum Render gehört, der den Handler erstellt hat; das Aufrufen des Setters ändert sie nicht:
Current render
count = 0
↓
setCount(1)
↓
Update scheduled
↓
Current render still has count = 0
↓
Next render gets count = 1
Closures erklären den Schnappschuss
Dieses Verhalten ist eine reine JavaScript-Closure-Semantik. Schauen Sie sich an:
function Counter() {
const [count, setCount] = useState(0);
function logCount() {
console.log(count);
}
return <button onClick={logCount}>Log</button>;
}
Jede Renderung definiert einen neuen logCount, wobei jeder seine eigene count-Wert speichert:
Render #1
count = 0
└── logCount closes over 0
Render #2
count = 1
└── logCount closes over 1
Ein alter Callback sieht stets die Werte seiner eigenen Renderung – das ist die Ursache für die meisten Fehler mit veralteten Werten.
Wie useEffect seine Abhängigkeiten merkt
Nehmen wir einen einfachen Effekt:
useEffect(() => {
console.log(count);
}, [count]);
React muss drei Dinge für ihn verfolgen:
Effect function
Dependencies
Cleanup function
Intern werden diese unter Namen, die diesen ähneln, im Hook des Effekts gespeichert:
Hook
├── create → effect function
├── deps → [count]
└── destroy → cleanup
Bei jeder Renderung vergleicht React den neuen Abhängigkeitsarray mit dem gespeicherten.
Vergleich der Abhängigkeiten
Mit demselben Effekt:
useEffect(() => {
console.log(count);
}, [count]);
die erste Renderung speichert auf:
count = 0
deps = [0]
und die nächste Renderung erzeugt:
count = 1
deps = [1]
React vergleicht die Arrays Element für Element:
Previous: [0]
New: [1]
Changed
↓
Effect needs to run
Etwas hat sich geändert, daher wird der Effect ausgeführt. Wenn eine spätere Darstellung dieselben Werte liefert:
Previous: [1]
New: [1]
Same
↓
Effect can be skipped
wird der Effect übersprungen. Jedes Element wird mit Object.is verglichen, was bedeutet, dass ein während der Darstellung neu erstelltes Objekt oder Array jedes Mal als verändert gilt.
Aufräumen
Ein Effect kann eine Funktion zurückgeben, die seine Arbeit rückgängig macht:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
Diese zurückgegebene Funktion ist das Aufräumen. Der Lebenszyklus des Intervalls sieht so aus:
Effect setup
↓
Interval created
↓
...
Cleanup
↓
Interval removed
Wenn Abhängigkeiten sich ändern können, ist das Aufräumen umso wichtiger:
useEffect(() => {
subscribe(userId);
return () => {
unsubscribe(userId);
};
}, [userId]);
Bei jeder Änderung von userId zerstört React den vorherigen Effect, bevor er einen neuen startet, und führt das finale Aufräumen aus, wenn die Komponente entmontiert wird:
Old effect
↓
Cleanup
↓
New effect
Effects synchronisieren sich, sie sind keine Lebenszyklusmethoden
Eine weit verbreitete Abkürzung besteht darin, useEffect mit einem leeren Array als componentDidMount zu behandeln. Eine nützlichere Sichtweise ist, dass ein Effect React mit einem System außerhalb von React in Einklang hält, wie zum Beispiel:
Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections
Zum Beispiel eine Chat-Verbindung:
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
Wenn sich roomId ändert, verlässt der Effect den alten Raum und tritt dem neuen bei, sodass die Verbindung stets zu den aktuellen Props passt.
useRef: ein stabiles Objekt
Ein Ref wird wie folgt erstellt:
const ref = useRef(0);
React gibt bei jeder Renderung genau dasselbe Objekt zurück:
Render #1
ref → Object A
Render #2
ref → Object A
Render #3
ref → Object A
Daher führt eine Zuweisung daran:
ref.current = 100;
keine Neuverarbeitung des Elements herbei, im Gegensatz zu einer State-Änderung:
setCount(100);
die eine Neuverarbeitung auslöst. Kurz gesagt der Unterschied:
useState
├── React-managed state
├── update schedules render
└── used for UI state
useRef
├── stable object
├── mutable .current
└── changing .current doesn't schedule render
Eine typische Anwendung ist das Speichern einer Timer-ID:
function Timer() {
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
}
return <button onClick={start}>Start</button>;
}
Die ID muss erhalten bleiben, aber niemals auf dem Bildschirm angezeigt werden – das ist die klassische Aufgabe eines Referenzwerts.
useMemo und useCallback
Eine gememorierte Berechnung sieht so aus:
const result = useMemo(
() => expensiveCalculation(data),
[data]
);
Der Hook speichert konzeptionell das letzte Ergebnis sowie die zur Berechnung verwendeten Abhängigkeiten:
Hook
├── memoizedValue
└── dependencies
Beim ersten Render wird die Berechnung ausgeführt:
data = A
calculate(A)
↓
result = X
Falls beim nächsten Render dieselben Daten übergeben werden:
data = A
bleiben die Abhängigkeiten unverändert:
A → A
daher wird das gespeicherte Ergebnis X zurückgegeben. Wenn data zu B wird, wird die Berechnung erneut ausgeführt.
Betrachten Sie dies als Optimierung, nicht als Garantie: React kann den Cache löschen, daher muss der Code auch bei erneuter Berechnung korrekt bleiben. Verwenden Sie es für aufwendige Aufgaben oder wenn eine stabile Referenz wichtig ist – nicht standardmäßig.
useCallback wendet denselben Ansatz auf Funktionen an:
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
Es speichert die Funktion und ihre Abhängigkeiten:
Hook
├── memoized function
└── dependencies
Unveränderte Abhängigkeiten liefern denselben Funktionsreferenz; veränderte liefern einen neuen. Dies ist insbesondere wichtig, wenn der Callback zu einem in React.memo eingebetteten Kindkomponenten geht, wo ein neuer Referenz die Memoisierung zunichtemachen würde:
const Child = React.memo(ChildComponent);
Custom Hooks sind einfach nur Funktionen
Hier ist ein kleiner Custom Hook:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
wird von einer Komponente verwendet:
function App() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
{count}
</button>
);
}
Ein Custom Hook ist keine separate Komponente. Es handelt sich um eine einfache Funktion, die aus anderen Hooks erstellt wird, und sein useState ist an die jeweils aktuell rendernde Komponente gebunden:
App Fiber
↓
useCounter()
↓
useState()
↓
Hook state
Zwei Komponenten, die useCounter aufrufen, erhalten daher zwei unabhängige Zähler.
Dispatchers: eine API, zwei Verhaltensweisen
Wenn Sie useState aufrufen, leitet die öffentliche Funktion an den jeweiligen Dispatcher weiter, den React für die aktuelle Darstellung installiert hat:
useState()
↓
Current Dispatcher
↓
mountState() / updateState()
Beim ersten Render handelt es sich um die Implementierung von mount:
Mount
↓
mountState()
und bei späteren Renders um die Implementierung von update:
Update
↓
updateState()
Mount versus Update für einen Hook
Für diesen Aufruf:
const [count, setCount] = useState(0);
mount erstellt die Strukturen:
Create Hook
↓
Store initial state
↓
Create update queue
während update sie wiederverwendet:
Find existing Hook
↓
Process pending updates
↓
Calculate new state
↓
Return state + dispatcher
Zusammenfassend:
FIRST RENDER
↓
Mount Hooks
SUBSEQUENT RENDERS
↓
Update Hooks
Darstellungsphase und Commit-Phase
Hooks ergänzen am besten Reacts zweiphasiges Darstellungsmodell:
State / Props Update
↓
Render Phase
↓
Reconciliation
↓
Commit Phase
↓
DOM
Während der Darstellung ruft React die Komponenten auf und berechnet die nächste Benutzeroberfläche:
Components execute
Hooks are processed
New UI is calculated
Während des Commits wendet er das Ergebnis an:
DOM mutations happen
Refs are updated
Layout effects are handled
Modern React kann die Render-Arbeit pausieren, neu starten oder ignorieren, während ein Commit immer abgeschlossen wird – deshalb muss die Render-Logik rein sein.
Reine Render-Logik beibehalten
Vermeiden Sie Nebeneffekte direkt im Komponentenkörper:
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
Das Rendering kann mehrfach ausgeführt werden oder vor dem Commit ignoriert werden, sodass diese Anfrage viele Male ausgelöst werden könnte. Verwenden Sie stattdessen eines davon:
Event handler
useEffect
Framework data-loading mechanism
Das Rendering sollte die UI nur auf der Grundlage der Eingaben berechnen:
UI = f(props, state)
Zusammenfassung: Ein Klick, von Anfang bis Ende
Hier ist eine Komponente, die State und einen Effect verwendet:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
Ein Klick durchläuft alle oben beschriebenen Schritte:
Click
↓
setCount()
↓
Update added to Hook queue
↓
React schedules work
↓
Counter renders
↓
useState retrieves/processes state
↓
useEffect checks dependencies
↓
React reconciles new UI
↓
Commit DOM changes
↓
Effect runs
Batching und warum sich der DOM nicht sofort aktualisiert
Ein Handler, der mehrere State-Werte setzt:
function handleClick() {
setCount(c => c + 1);
setName("Salim");
setVisible(true);
}
Es führt nicht zu drei separaten Darstellungen. Seit React 18 werden Updates automatisch gebündelt, einschließlich jener in Timeout-Funktionen und Promises:
setCount()
setName()
setVisible()
↓
Batch
↓
Render
↓
Commit
anstatt:
Update → Render → Commit
Update → Render → Commit
Update → Render → Commit
Das Bündeln ist an sich eine Methode, um unnötige Arbeiten zu vermeiden. Derselbe Grund erklärt auch, warum ein Aufruf wie:
setCount(10);
nicht unmittelbar bedeutet:
state = 10
DOM = 10
Sondern er läuft durch den Pipeline-Prozess:
setCount(10)
↓
Schedule update
↓
React render
↓
Reconciliation
↓
Commit
↓
DOM update
Durch Verschieben gibt React die Möglichkeit, Aufgaben zu bündeln, zu priorisieren und überflüssige Arbeiten auszulassen; flushSync aus react-dom erzwingt eine synchrone Aktualisierung, wenn sie tatsächlich erforderlich ist.
Das gesamte Modell in einem Überblick
Alles oben passt in ein einziges Bild, von der Komponente bis zu den Pixeln auf dem Bildschirm:
FUNCTION COMPONENT
│
▼
Fiber
│
▼
Hook sequence
│
┌───────────────┼───────────────┐
▼ ▼ ▼
useState useRef useEffect
│ │ │
▼ ▼ ▼
Update Queue Stable Object Dependencies
│
▼
setState()
│
▼
Schedule Update
│
▼
Render
│
▼
Reconciliation
│
▼
Commit
│
▼
DOM
│
▼
Browser
│
▼
Pixels
Wichtige Erkenntnisse
- Der Zustand eines Hooks existiert im Fiber des Components und wird durch seine Position mit jedem Aufruf abgeglichen, weshalb die Reihenfolge der Hooks niemals geändert werden darf.
setStatestellt einen Update-Verarbeitungsauftrag an; der neue Wert wird bei der nächsten Darstellung verwendet, während die aktuelle Darstellung ihren Zustands-Snapshot beibehält.- Verwenden Sie funktionale Updates, wenn ein Update vom vorherigen Wert abhängt.
- Effects synchronisieren sich mit externen Systemen; Abhängigkeiten werden mithilfe von
Object.isverglichen, und die Aufräumarbeiten finden vor erneuten Ausführungen sowie beim Entfernen des Components statt. - Refs sind stabile, veränderliche Strukturen, die niemals eine Neu-Darstellung auslösen; Memoisierung ist eine Optimierung, keine Garantie.
- Kundenspezifische Hooks teilen Logik, nicht Zustand, und die Darstellung muss rein bleiben, da React sie wiederholen oder ignorieren kann.