Optymalizacja React: Hook useCallback
Stabilność referencyjna funkcji
Problem z funkcjami w React
Znasz już mechanizm działania komponentów funkcyjnych w React. Za każdym razem, gdy stan lub propsy komponentu ulegają zmianie, React ponownie wykonuje jego funkcję, aby wygenerować nową wersję interfejsu. Ten proces, zwany renderowaniem, ma kluczową, choć czasem problematyczną konsekwencję: wszystko, co jest zdefiniowane wewnątrz ciała komponentu, jest tworzone na nowo.
Dotyczy to nie tylko zmiennych, ale również funkcji. Przyjrzyjmy się prostemu przykładowi:
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// Ta funkcja jest tworzona na nowo przy każdym renderze
const handleIncrement = () => {
console.log("Tworzenie nowej funkcji handleIncrement");
setCount(c => c + 1);
};
return (
<div>
<p>Licznik: {count}</p>
<button onClick={handleIncrement}>Zwiększ</button>
</div>
);
}
Za każdym razem, gdy klikniesz przycisk, stan count się zmienia, co powoduje ponowne renderowanie komponentu Counter. W trakcie tego procesu, funkcja handleIncrement jest deklarowana i tworzona od zera. W tym prostym przypadku nie ma to większego znaczenia dla wydajności. Problem pojawia się, gdy taką funkcję przekażemy jako props do komponentu potomnego.
Równość, która nie jest oczywista
Aby zrozumieć, dlaczego ponowne tworzenie funkcji jest problemem, musimy najpierw zrozumieć, jak JavaScript porównuje wartości. W przypadku typów prostych, takich jak liczby, ciągi znaków czy wartości logiczne, porównanie jest proste. 5 === 5 zawsze zwróci true.
Jednak w przypadku typów złożonych, takich jak obiekty, tablice i funkcje, JavaScript stosuje mechanizm zwany (referential equality). Oznacza to, że dwie zmienne są sobie równe tylko wtedy, gdy wskazują na dokładnie ten sam obiekt w pamięci komputera. Dwa obiekty o identycznej strukturze i zawartości, ale istniejące w dwóch różnych miejscach w pamięci, nie będą sobie równe.
Dwie funkcje, nawet jeśli mają identyczny kod, są równe tylko wtedy, gdy odnoszą się do tej samej definicji w pamięci.
const funcA = () => console.log('hello');
const funcB = () => console.log('hello');
const funcC = funcA;
console.log(funcA === funcB); // false - dwie różne funkcje w pamięci
console.log(funcA === funcC); // true - obie zmienne wskazują na tę samą funkcję
W kontekście Reacta, przy każdym renderowaniu komponentu Counter, deklaracja const handleIncrement = () => ... tworzy zupełnie nową funkcję. Z perspektywy JavaScriptu, handleIncrement z poprzedniego renderu i handleIncrement z obecnego renderu to dwie różne funkcje, mimo że ich kod jest identyczny.
Kaskada niepotrzebnych renderów
React domyślnie renderuje ponownie komponent potomny, gdy tylko jego rodzic zostanie wyrenderowany. Możemy zoptymalizować ten proces, używając React.memo. Komponent owinięty w React.memo zostanie ponownie wyrenderowany tylko wtedy, gdy któryś z jego propsów ulegnie zmianie.
Problem w tym, że React.memo wykonuje tak zwane (shallow comparison) propsów. Dla typów złożonych, takich jak funkcje, jest to właśnie porównanie referencyjne.
Rozważmy taki scenariusz:
// Komponent potomny zoptymalizowany za pomocą React.memo
const DisplayButton = React.memo(({ onAction }) => {
console.log("Renderuję DisplayButton...");
return <button onClick={onAction}>Kliknij mnie</button>;
});
function Parent() {
const [value, setValue] = useState(0);
const doSomething = () => {
console.log("Akcja wykonana!");
};
return (
<div>
{/* Przycisk do wywołania re-renderu rodzica */}
<button onClick={() => setValue(v => v + 1)}>Re-renderuj rodzica</button>
<DisplayButton onAction={doSomething} />
</div>
);
}
W powyższym kodzie, za każdym razem, gdy klikniemy przycisk "Re-renderuj rodzica", komponent Parent renderuje się ponownie. W trakcie tego procesu tworzona jest nowa funkcja doSomething. Następnie ta nowa funkcja jest przekazywana jako onAction do DisplayButton.
Mimo że użyliśmy React.memo, DisplayButton i tak będzie renderowany za każdym razem. Dlaczego? Bo z perspektywy płytkiego porównania, props onAction zawsze się zmienia. Za każdym razem otrzymuje referencję do nowej, świeżo utworzonej funkcji.
To jest sedno problemu stabilności referencyjnej. Nasze próby optymalizacji za pomocą React.memo są niweczone przez niestabilne referencje funkcji przekazywanych w propsach. Właśnie ten problem rozwiązuje hook useCallback.
Co dzieje się z funkcjami zdefiniowanymi wewnątrz komponentu funkcyjnego React za każdym razem, gdy komponent jest ponownie renderowany z powodu zmiany stanu lub propsów?
Jak JavaScript porównuje dwie zmienne, z których każda przechowuje obiekt lub funkcję, używając operatora ścisłego porównania (===)?