Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP14 min czytania3107 słów

Ź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.
  • setState umieszcza 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ć.