Stwórz funkcję useState do zabawek, aby zrozumieć zrzuty stanu, kolejki i efekty.
Dowiedz się, jak zachowują się hooki w React, tworząc prosty useState, a następnie wykorzystaj ten model do wyjaśnienia kolejek aktualizacji, przestarzałych zrzutów stanu, zamknięć funkcji, czyszczenia efektów, referencji oraz grupowania operacji.
Źródła: React Hooks Internally: How `useState`, `useEffect`& Other Hooks Actually Work
Jedna linia kodu hooka kryje zaskakująco dużo mechanizmów:
const [count, setCount] = useState(0);
React musi zdecydować, gdzie przechowywany jest count, jak przetrwa on każde wezwanie, jaki stan ma na myśli dany setter, dlaczego wywołania hooków nie mogą być warunkowe oraz jak efekty śledzą zależności. Ten przewodnik tworzy mały, działający model hooków i wykorzystuje go do wyjaśnienia zachowań, które codziennie sprawiają problemy: przestarzałe wartości po setState, aktualizacje funkcyjne, czyszczenie efektów oraz grupowanie zadań.
Stan nie znajduje się w funkcji
Weź najprostszą możliwą komponentę z stanem:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
Może się wydawać, że count znajduje się wewnątrz Counter, ale React po prostu ponownie wywołuje tę funkcję przy każdym renderowaniu:
Render 1
Counter()
count = 0
Render 2
Counter()
count = 1
Render 3
Counter()
count = 2
Zmienne lokalne znikają po zakończeniu wykonywania funkcji, dlatego React przechowuje stan hooków poza komponentem, przywiązany do jego Fiber, i zwraca przechowywaną wartość przy każdym renderowaniu.
Fiber przechowuje sekwencję hooków
W uproszczonej formie związek ten wygląda następująco:
Component
│
▼
Fiber
│
▼
Hook data
│
┌──┼───────────────┐
▼ ▼ ▼
State Ref Effect
Fiber to jednostka pracy Reacta, tworzona zarówno dla komponentów, jak i elementów hostujących. Dla komponentu funkcyjnego przenosi dane hooków, koncepcyjnie jako łańcuch:
Counter Fiber
│
▼
Hook #1
│
▼
Hook #2
│
▼
Hook #3
Rzeczywista implementacja zawiera o wiele więcej pól, ale ten obraz wystarcza do zrozumienia podstawowego zachowania.
Hooki są dopasowywane według kolejności wywołań
Oto komponent z trzema hookami:
function Component() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
useEffect(() => {
console.log(count);
}, [count]);
return ...;
}
React nie ma nazw dla tych hooków; zna jedynie „pierwszy”, „drugi” i „trzeci” oraz zakłada, że pojawiają się one w tej kolejności przy każdym renderowaniu:
Fiber
│
├── Hook #1 → useState
│
├── Hook #2 → useState
│
└── Hook #3 → useEffect
To założenie stanowi całkowitą podstawę Zasad Hooków.
Co idzie nie tak z warunkowym hookiem
Rozważmy teraz hook ukryty za warunkiem:
function Component({ loggedIn }) {
if (loggedIn) {
useState("Salim"); // ❌
}
useState(0);
return ...;
}
Podczas renderowania, gdy użytkownik jest zalogowany, wywoływane są dwa hooki:
loggedIn = true
Hook #1 → name
Hook #2 → count
Jeśli loggedIn później stanie się fałszywe, następuje tylko jedno wywołanie:
loggedIn = false
Hook #1 → count
Pozycja przeznaczona do przechowywania nazwy teraz otrzymuje wywołanie liczące, więc stan jest sparowany z niewłaściwym hookiem:
Before:
1 → name
2 → count
After:
1 → count
Dlatego kolejność hooków nigdy nie może się zmieniać pomiędzy renderowaniami, co wyklucza wywołania w takich miejscach:
if / else
loops
nested functions
event handlers
Zachowaj je na najwyższym poziomie komponentów i niestandardowych hooków; narzędzie lintingowe dla React Hooków to egzekwuje.
Sztuczny useState w dwudziestu liniach
Budowa wersji minimalnej sprawia, że mechanizm staje się konkretny. Poniższy szkic zawiera tablicę miejsc na haczyki oraz wskaźnik. Każda wywołanie useState zajmuje miejsce wskazane przez wskaźnik, inicjalizuje je tylko wtedy, gdy jest puste, zwraca funkcję do ustawienia wartości powiązaną z tym indeksem i przesuwa wskaźnik:
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];
}
Dwie rzeczy są domyślne, ale nie pokazane: render() musi zresetować currentHook do 0 przed ponownym wywołaniem komponentu, a także musi go ponownie uruchomić. W przypadku takiego komponentu:
function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Salim");
return ...;
}
przechowywanie danych wygląda wtedy następująco:
hooks[0]
state = 0
hooks[1]
state = "Salim"
Podczas kolejnego renderowania każde wywołanie znajduje swoje miejsce wyłącznie na podstawie pozycji.
Gdzie kończy się ten uproszczony model
Real React dodaje obiekty hooków do Fibers, kolejki aktualizacji, planer z torami priorytetowymi, dyspetytory montażu i aktualizacji, logikę synchronizacji i zatwierdzania; nie ma żadnego globalnego tablicy. Podstawowa idea pozostaje ta sama:
Stable Hook order
+
React-managed Hook state
+
Component Fiber
Jak działa useState w React
Dla tej wywołania:
const [count, setCount] = useState(0);
React potrzebuje dwóch rzeczy dla każdego hooka stanu:
State
+
Update queue
W uproszczeniu hook przechowuje swoją aktualną wartość w memoizedState oraz kolejkę oczekujących aktualizacji:
Hook
├── memoizedState → 0
│
└── queue
└── pending updates
Pierwszy render: montaż
Dla tego komponentu:
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
pierwszy render tworzy hook, przechowuje wartość początkową, ustawia pustą kolejkę i zwraca tę parę:
Component renders
↓
useState(0)
↓
Create Hook
↓
Store initial state
↓
Create update queue
↓
Return [0, setCount]
Fiber wygląda teraz mniej więcej w ten sposób, a ta faza nazywa się montażem:
Fiber
│
└── Hook
├── state: 0
└── queue
Późniejsze rendery: aktualizacja
Załóżmy, że obsługa zdarzenia wywołuje:
setCount(5);
React planuje aktualizację. Podczas następnego renderowania useState nic nie tworzy; znajduje istniejący hook, aplikuje zapisane aktualizacje i zwraca wynik:
Component renders
↓
useState(0)
↓
React finds existing Hook
↓
Process queued updates
↓
New state = 5
↓
Return [5, setCount]
Przekazana wartość 0 jest zatem wartością początkową, a nie operacją sformatowania. Ma znaczenie tylko przy pierwszym utworzeniu hooka.
setState to żądanie, a nie przypisanie
Taka funkcja wywołania:
setCount(5);
nie oznacza „ustaw count na 5 teraz”. Zapisuje to żądanie, które React obsługuje według własnego harmonogramu:
setCount(5)
↓
Create update
↓
Add update to queue
↓
Schedule React work
↓
Render component
↓
Process update queue
↓
Calculate new state
↓
Reconcile UI
↓
Commit changes
Aktualizacje są kolejno zapisywane w kolejkę
Rozważmy trzy kolejne aktualizacje funkcyjne:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
React przechowuje je w kolejkę, startując od aktualnej wartości:
Update Queue
c => c + 1
c => c + 1
c => c + 1
If the current state is: 0
i aplikuje je jedna po drugiej:
0 → 1
1 → 2
2 → 3
Wynikiem jest 3, dlatego aktualizacje funkcyjne nadają się do zastosowań, które zależą od poprzedniej wartości.
Dlaczego przekazywanie wartości zachowuje się inaczej
Porównaj wersję, która przekazuje wartości:
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
Jeśli aktualna renderizacja ma count równy 0, wszystkie trzy wyrażenia są obliczane przed przetworzeniem jakiejkolwiek aktualizacji, więc dają one:
setCount(1)
setCount(1)
setCount(1)
Ostateczny stan to 1. Z użyciem funkcji aktualizujących:
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1);
każda funkcja otrzymuje wynik poprzedniej, co daje kolejno 0, 1, 2, a na końcu 3.
Stan to zrzut ekranu jednej renderizacji
Ten mechanizm zaskakuje wielu programistów:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
Log wyświetla 0, a nie 1. count jest stałą należącą do renderizacji, która stworzyła ten mechanizm; wywołanie funkcji ustawiającej jej wartość jej nie zmienia:
Current render
count = 0
↓
setCount(1)
↓
Update scheduled
↓
Current render still has count = 0
↓
Next render gets count = 1
Zamknięcia wyjaśniają zasadę zrzutu ekranu
To zachowanie to prosta semantyka zamknięć w JavaScript. Spójrz:
function Counter() {
const [count, setCount] = useState(0);
function logCount() {
console.log(count);
}
return <button onClick={logCount}>Log</button>;
}
Każda renderizacja definiuje nową wartość logCount, a każda z nich przechowuje wartość count swojej własnej renderizacji:
Render #1
count = 0
└── logCount closes over 0
Render #2
count = 1
└── logCount closes over 1
Stary callback zawsze widzi wartości swojej własnej renderizacji, co jest przyczyną większości błędów związanych ze starymi wartościami.
Jak useEffect pamięta swoje zależności
Weźmy prosty efekt:
useEffect(() => {
console.log(count);
}, [count]);
React musi śledzić trzy rzeczy:
Effect function
Dependencies
Cleanup function
Wewnętrznie są one przechowywane w hucie efektu pod nazwami zbliżonymi do tych:
Hook
├── create → effect function
├── deps → [count]
└── destroy → cleanup
Przy każdej renderizacji React porównuje nowy array zależności z tym przechowywanym.
Porównywanie zależności
Używając tego samego efektu:
useEffect(() => {
console.log(count);
}, [count]);
pierwsza renderizacja zapisuje:
count = 0
deps = [0]
a następna renderizacja wytwarza:
count = 1
deps = [1]
React porównuje tablice element po elemencie:
Previous: [0]
New: [1]
Changed
↓
Effect needs to run
Coś się zmieniło, więc wykonywany jest efekt. Jeśli kolejne renderowanie daje te same wartości:
Previous: [1]
New: [1]
Same
↓
Effect can be skipped
efekt jest pomijany. Każdy element jest porównywany za pomocą Object.is, co oznacza, że obiekt lub tablica stworzona podczas renderowania jest uważana za zmienioną za każdym razem.
Cleanup
Efekt może zwracać funkcję, która cofa jego działanie:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(id);
};
}, []);
Ta zwrócona funkcja to czynność cleanup. Życiowy cykl interwału wygląda w ten sposób:
Effect setup
↓
Interval created
↓
...
Cleanup
↓
Interval removed
Gdy zależności mogą się zmieniać, czynność cleanup jest jeszcze ważniejsza:
useEffect(() => {
subscribe(userId);
return () => {
unsubscribe(userId);
};
}, [userId]);
Przy każdej zmianie userId React niszczy poprzedni efekt przed uruchomieniem nowego i wykonywać końcową czynność cleanup, gdy komponent jest usuwany:
Old effect
↓
Cleanup
↓
New effect
Efekty synchronizują się, nie są metodami życiowymi
Powszechnym sposobem obejścia jest traktowanie useEffect z pustym tablicą jak componentDidMount. Bardziej przydatne ujęcie brzmi tak, że efekt utrzymuje React w synchronizacji z jakimś systemem spoza React, na przykład:
Network requests
Timers
Subscriptions
Browser APIs
DOM APIs
Third-party libraries
WebSocket connections
Np. połączeniem w czacie:
useEffect(() => {
const connection = connect(roomId);
return () => {
connection.disconnect();
};
}, [roomId]);
Gdy roomId się zmienia, efekt opuszcza stary pokój i dołącza do nowego, dzięki czemu połączenie zawsze odpowiada aktualnym propom.
useRef: jeden stabilny obiekt
Ref tworzy się w ten sposób:
const ref = useRef(0);
React zwraca dokładnie ten sam obiekt przy każdym renderowaniu:
Render #1
ref → Object A
Render #2
ref → Object A
Render #3
ref → Object A
W rezultacie przypisanie do niego:
ref.current = 100;
nie powoduje renderowania, w odróżnieniu od aktualizacji stanu:
setCount(100);
która ją zaplanowuje. Krótko mówiąc, różnica polega na tym:
useState
├── React-managed state
├── update schedules render
└── used for UI state
useRef
├── stable object
├── mutable .current
└── changing .current doesn't schedule render
Typowym zastosowaniem jest przechowywanie identyfikatora timera:
function Timer() {
const timerRef = useRef(null);
function start() {
timerRef.current = setInterval(() => {
console.log("tick");
}, 1000);
}
return <button onClick={start}>Start</button>;
}
ID musi przetrwać, ale nigdy nie pojawić się na ekranie – to klasyczne zastosowanie referencji.
useMemo i useCallback
Obliczenie zapisane w pamięci wygląda tak:
const result = useMemo(
() => expensiveCalculation(data),
[data]
);
Hook konceptualnie przechowuje ostatni wynik oraz zależności użyte do jego obliczenia:
Hook
├── memoizedValue
└── dependencies
Podczas pierwszego renderowania wykonywane jest obliczenie:
data = A
calculate(A)
↓
result = X
Jeśli przy następnym renderowaniu przekazane są te same dane:
data = A
zależności pozostają niezmienione:
A → A
dlatego zwracany jest przechowywany wynik X. Gdy data staje się B, obliczenie jest wykonywane ponownie.
Traktuj to jako optymalizację, a nie gwarancję: React może usunąć cache, więc kod musi pozostać poprawny nawet w przypadku ponownego obliczenia wartości. Należy go używać do kosztownych operacji lub gdy ważna jest stabilna referencja, a nie jako standard.
useCallback stosuje tę samą zasadę do funkcji:
const handleClick = useCallback(() => {
doSomething(id);
}, [id]);
Zachowuje funkcję oraz jej zależności:
Hook
├── memoized function
└── dependencies
Zależności, które się nie zmieniły, zwracają tę samą referencję funkcji; te, które się zmieniły, zwracają nową. Ma to znaczenie przede wszystkim wtedy, gdy callback trafia do potomka otoczonego React.memo, gdzie nowa referencja mogłaby zniweczyć mechanizm memoizacji:
const Child = React.memo(ChildComponent);
Custom hooks to po prostu funkcje
Oto mały custom hook:
function useCounter() {
const [count, setCount] = useState(0);
function increment() {
setCount(c => c + 1);
}
return {
count,
increment
};
}
używany przez komponent:
function App() {
const { count, increment } = useCounter();
return (
<button onClick={increment}>
{count}
</button>
);
}
Custom hook nie jest oddzielnym komponentem. Jest to zwykła funkcja stworzona na bazie innych hooków, a jej useState jest powiązany z tym komponentem, który aktualnie się renderuje:
App Fiber
↓
useCounter()
↓
useState()
↓
Hook state
Dwa komponenty wywołujące useCounter otrzymują zatem dwa niezależne liczniki.
Dispatchery: jedna API, dwa zachowania
Gdy wywołujesz useState, ta publiczna funkcja kieruje żądanie do mechanizmu obsługi, który React zainstalował dla bieżącego renderowania:
useState()
↓
Current Dispatcher
↓
mountState() / updateState()
Podczas pierwszego renderowania jest to implementacja mount:
Mount
↓
mountState()
a podczas późniejszych renderowań – implementacja update:
Update
↓
updateState()
Mount versus update dla jednego hooka
Dla tego wywołania:
const [count, setCount] = useState(0);
mount tworzy struktury:
Create Hook
↓
Store initial state
↓
Create update queue
podczas gdy update je ponownie wykorzystuje:
Find existing Hook
↓
Process pending updates
↓
Calculate new state
↓
Return state + dispatcher
Podsumowując:
FIRST RENDER
↓
Mount Hooks
SUBSEQUENT RENDERS
↓
Update Hooks
Faza renderowania i faza zatwierdzenia
Hooki mają największy sens w kontekście dwufazowego modelu renderowania Reacta:
State / Props Update
↓
Render Phase
↓
Reconciliation
↓
Commit Phase
↓
DOM
Podczas renderowania React wywołuje komponenty i oblicza następny interfejs użytkownika:
Components execute
Hooks are processed
New UI is calculated
Podczas fazy zatwierdzenia aplikuje uzyskany wynik:
DOM mutations happen
Refs are updated
Layout effects are handled
Współczesny React może wstrzymywać, restartować lub odrzucać zadania renderowania, podczas gdy commit zawsze się kończy, dlatego logika renderowania musi być czysta.
Zachowanie czystości renderowania
function Component() {
fetch("/api/users"); // ❌
return <div>Hello</div>;
}
Renderowanie może być wykonywane wielokrotnie lub odrzucane przed commitem, więc ta prośba może zostać wysłana wiele razy. Zamiast tego użyj jednego z poniższych rozwiązań:
Event handler
useEffect
Framework data-loading mechanism
Renderowanie powinno obliczać interfejs użytkownika wyłącznie na podstawie danych wejściowych:
UI = f(props, state)
Łączenie wszystkiego: jeden kliknięcie, od początku do końca
Oto komponent wykorzystujący stan i efekt:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return (
<button onClick={() => setCount(c => c + 1)}>
{count}
</button>
);
}
Kliknięcie przechodzi przez wszystkie powyższe etapy:
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
Grupowanie zadań i dlaczego DOM nie aktualizuje się natychmiast
Obsługa zdarzenia, która ustawia kilka wartości stanu:
function handleClick() {
setCount(c => c + 1);
setName("Salim");
setVisible(true);
}
nie powoduje trzech oddzielnych renderowań. Od wersji React 18 aktualizacje są automatycznie grupowane, włączając te z timeoutów i obietnic:
setCount()
setName()
setVisible()
↓
Batch
↓
Render
↓
Commit
Zamiast tego:
Update → Render → Commit
Update → Render → Commit
Update → Render → Commit
Grupowanie to sama w sobie metoda unikania niepotrzebnej pracy.
To samo rozumowanie wyjaśnia, dlaczego taka funkcja jak:
setCount(10);
nie oznacza od razu:
state = 10
DOM = 10
Zamiast tego przechodzi przez cały proces:
setCount(10)
↓
Schedule update
↓
React render
↓
Reconciliation
↓
Commit
↓
DOM update
Odkładanie daje React możliwość grupowania, ustalania priorytetów i pomijania zbędnych zadań; flushSync z react-dom wymusza synchroniczną aktualizację tylko wtedy, gdy jest to naprawdę konieczne.
Cały model w pigułce
Wszystko powyżej mieści się w jednym obrazie, od komponentu aż po piksele na ekranie:
FUNCTION COMPONENT
│
▼
Fiber
│
▼
Hook sequence
│
┌───────────────┼───────────────┐
▼ ▼ ▼
useState useRef useEffect
│ │ │
▼ ▼ ▼
Update Queue Stable Object Dependencies
│
▼
setState()
│
▼
Schedule Update
│
▼
Render
│
▼
Reconciliation
│
▼
Commit
│
▼
DOM
│
▼
Browser
│
▼
Pixels
Główne wnioski
- Stan hooka istnieje w Fiber komponentu i jest dopasowywany do każdej wywołania według pozycji, dlatego kolejność hooków nigdy nie może ulec zmianie.
setStateumieszcza aktualizację w kolejce; nowa wartość pojawia się podczas następnego renderowania, a obecne renderowanie zachowuje swój snapshot.- Używaj aktualizacji funkcyjnych, gdy aktualizacja zależy od poprzedniej wartości.
- Efekty synchronizują się z zewnętrznymi systemami; zależności są porównywane za pomocą
Object.is, a czyszczenie odbywa się przed ponownym uruchomieniem oraz przy demontażu. - Refs to stabilne, zmienne struktury, które nigdy nie wyzwalają renderowania; memoizacja to optymalizacja, a nie gwarancja.
- Custom hooki dzielą się logiką, a nie stanem, i render musi pozostać czysty, ponieważ React może go powtórzyć lub odrzucić.