Drążenie otworów nie jest powodem do instalacji Redux lub Zustand.
Przetestuj cztery powszechne powody dodawania biblioteki stanowej do działającego kodu React: Context w celu przenoszenia wartości propów, useState, useSyncExternalStore oraz koszt ponownego renderowania Context.
Zadaj zespołowi pytanie, dlaczego w ich aplikacji React używają Redux, Zustand lub MobX – zazwyczaj pierwszą odpowiedzią jest problem z przekazywaniem wartości między komponentami. Jest to rzeczywiście irytujące, ale nie stanowi dobrego powodu do dodawania zależności, podobnie jak trzy kolejne uzasadnienia, które się pojawiają. Poniżej każde z tych czterech powodów jest sprawdzone na małym, działającym przykładzie, wraz z tym, co React już oferuje w takich sytuacjach, oraz w przypadku, gdy biblioteka do zarządzania stanem rzeczywiście się opłaca.
Cztery powszechne uzasadnienia
Argumenty zazwyczaj pojawiają się w przewidywalnej kolejności:
- Przekazywanie wartości między komponentami, które jej nie używają, jest uciążliwe, więc aplikacja potrzebuje sklepu stanu.
- Stan klienta, który faktycznie się zmienia, np. flaga tematu jasnego lub ciemnego, z pewnością wymaga biblioteki.
- Aby każdy inny komponent, który odczytuje ten stan, aktualizował się automatycznie, bez ręcznego przekazywania właściwości, konieczna jest taka biblioteka.
Każde z tych podejść zawodzi, gdy spojrzy się na konkretny kod.
Prop drilling: problem, który React rozwiązał już lata temu
Prop drilling oznacza przekazywanie wartości przez kilka warstw wyłącznie po to, by głęboko zagnieżdżony komponent mógł ją odczytać, podczas gdy żadna z warstw pośrednich jej nie używa. Cztery małe pliki pokazują taką strukturę. App przechowuje obiekt user w stanie i przekazuje go do Dashboard.
// App.jsx
import { useState } from 'react';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return <Dashboard user={user} />;
}
export default App;
Dashboard nic nie robi z user, poza przekazaniem go do Sidebar.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard({ user }) {
return <Sidebar user={user} />;
}
export default Dashboard;
Sidebar robi to samo, rozpakowując dwa pola dla Avatar.
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar({ user }) {
return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
}
export default Sidebar;
Tylko Avatar faktycznie wyświetla dane.
// components/Avatar.jsx
function Avatar({ name, avatarUrl }) {
return <img src={avatarUrl} alt={name} />;
}
export default Avatar;
Ani Dashboard.jsx, ani Sidebar.jsx nie czytają wartości user do swojej logiki. Są jedynie przekaźnikami tych danych. Jeśli avatarUrl zostanie przemianowane na photoUrl, oba pliki wymagają modyfikacji, mimo że ich zachowanie się nie zmieniło, po prostu dlatego, że przechowują dane, które do nich nie należą.
Kontekst dostarcza wartość bezpośrednio
Wbudowanym rozwiązaniem w React jest Context. Najpierw tworzy się obiekt kontekstu raz, w specjalnym module.
// context/UserContext.js
import { createContext } from 'react';
const UserContext = createContext(null);
export default UserContext;
Następnie App otacza poddrzewo elementem Provider kontekstu i przekazuje wartość user jako jego zawartość, zamiast przekazywać ją jako właściwość.
// App.jsx
import { useState } from 'react';
import UserContext from './context/UserContext';
import Dashboard from './components/Dashboard';
function App() {
const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
return (
<UserContext.Provider value={user}>
<Dashboard />
</UserContext.Provider>
);
}
export default App;
Dashboard i Sidebar sprowadzają się do czystej struktury rozkładu, bez żadnej wzmianki o user.
// components/Dashboard.jsx
import Sidebar from './Sidebar';
function Dashboard() {
return <Sidebar />;
}
export default Dashboard;
// components/Sidebar.jsx
import Avatar from './Avatar';
function Sidebar() {
return <Avatar />;
}
export default Sidebar;
Ostatecznie Avatar sam odczytuje tę wartość.
// components/Avatar.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';
function Avatar() {
const user = useContext(UserContext);
return <img src={user.avatarUrl} alt={user.name} />;
}
export default Avatar;
Każdy z tych trzech elementów ma jedno zadanie. Funkcja createContext tworzy kanał niezależny od propów. Komponent Provider umożliwia natychmiastowe dostępne dla całego swojego poddrzewa użycie zmiennej user. Funkcja useContext(UserContext) odczytuje tę wartość od najbliższego komponentu Provider powyżej danego komponentu, pomijając wszystkie pośrednie warstwy. Komponenty pośrednie przestają używać zmiennej user, ponieważ nigdy jej nie potrzebowały. Należy dodać, że React 19 pozwala również na bezpośrednie wyświetlanie obiektu kontekstu jako providera, ale przedstawiona tutaj forma .Provider nadal funkcjonuje.
Dlaczego argument dotyczący „drillingu” przez propy przetrwał swój pierwotny powód
Historia wyjaśnia, dlaczego ten argument wciąż istnieje. Redux pojawił się w czerwcu 2015 roku, stworzony przez Dana Abramova i Andrew Clarka, opierając się na architekturze Flux firmy Facebook: pojedynczym centralnym magazynie z ścisłymi zasadami dotyczącymi sposobu zmiany stanu. API Context w React stało się stabilną, oficjalnie wspieraną funkcją dopiero w wersji 16.3, w marcu 2018 roku, prawie trzy lata później. We wczesnym okresie rozwoju Redux magazyn rzeczywiście stanowił praktyczne rozwiązanie umożliwiające dostęp do wartości w dowolnym miejscu struktury, bez konieczności przekazywania jej przez każdy komponent.
To przestało być prawdą, gdy Context stał się stabilny, ale nauczanie nie nadrobiło tego opóźnienia. Tutorials wciąż przedstawiały problem wynikający z konieczności przekazywania wartości przez wszystkie poziomy jako powód do stosowania tej metody, ponieważ łatwo jest to przedstawić na tablicy, a ten nawyk przetrwał długo po zniknięciu pierwotnego powodu jego stosowania.
Burzenie otworów wiertniczych dotyczy jednak dystrybucji, a nie zmiany. Następnym pytaniem jest to, co się dzieje, gdy rozproszona wartość zaczyna ulegać zmianie na stronie klienta.
Zmiana stanu to dokładnie to, co robi useState
Weźmy na przykład przełącznik tematu: pojedynczą flagę reprezentującą light lub dark, którą zmienia przycisk znajdujący się tuż obok tekstu pokazującego jej wartość.
// components/SettingsPanel.jsx
import { useState } from 'react';
function SettingsPanel() {
const [theme, setTheme] = useState('light');
return (
<div>
<p>Current theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
export default SettingsPanel;
Kliknięcie w przycisk wywołuje setTheme, SettingsPanel ponownie się renderuje z nową wartością, a akapit zostaje zaktualizowany. Nie jest potrzebna żadna biblioteka, a także jej nie ma. Posiadanie wartości, jej zmiana oraz ponowny render właściciela to dokładnie to, do czego służy useState, i każde aplikacje React robią to ciągle.
Przykład jest zazwyczaj przedstawiany w wersji zmodyfikowanej, która wykonuje całą rzeczywistą pracę. Trudność nie polega na zmianie wartości, lecz na tym, że wartość ta jest odczytywana gdzie indziej. Wyobraźmy sobie, że przycisk pozostaje w pliku SettingsPanel.jsx, podczas gdy Header.jsx i Sidebar.jsx – dwa niepowiązane pliki, które ani nie importują SettingsPanel, ani nie są przez niego importowane – również muszą wyświetlać obecny temat. Stan utworzony za pomocą useState należy do pojedynczej instancji komponentu. Nic poza tym komponentem nie może go odczytać ani zostać poinformowane o zmianach, chyba że wspólny przodek przekaże go dalej, co przywraca problem przenoszenia wartości przez pośredniki.
Zatem useState doskonale radzi sobie z obsługą zmian. Otwartym pytaniem pozostaje to, jak dwa komponenty bez wspólnego rodzica mogą automatycznie się aktualizować bez użycia propów.
Dzielenie się stanem pomiędzy niepowiązanymi komponentami za pomocą useSyncExternalStore
Jeśli ani Header, ani Sidebar nie mogą przechowywać tej wartości, musi ona znajdować się poza drzewem komponentów: w zmiennych na poziomie modułu, które są inicjalizowane tylko raz, przy pierwszym imporcie pliku, a nie wewnątrz jakiejś funkcji komponentu. React nigdy nie widzi, aby zwykła zmienna była przypisywana na nowo, więc nie może samodzielnie ponownie wyrenderować żadnego elementu, gdy ta wartość się zmienia. Mały moduł typu store wypełnia tę lukę.
// store/themeStore.js
import { useSyncExternalStore } from 'react';
let state = { theme: 'light' };
const listeners = new Set();
export function setTheme(theme) {
state = { ...state, theme };
listeners.forEach((listener) => listener());
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useTheme() {
return useSyncExternalStore(subscribe, () => state.theme);
}
Moduł zawiera obiekt state oraz zbiór Set słuchaczy. Funkcja setTheme zastępuje obiekt state nowym obiektem i wywołuje każdego słuchacza. Prywatna funkcja rejestracji dodaje słuchacza do tego zbioru i zwraca funkcję do usunięcia, która go usuwa. Funkcja useTheme łączy to wszystko z React za pomocą useSyncExternalStore – hooka zaprojektowanego specjalnie dla stanu znajdującego się poza komponentami, który jest odczytywany przez kilka z nich.
Hook przyjmuje tutaj dwa argumenty. Pierwszy, czyli funkcja rejestracji, informuje React, jak dodać funkcję zwrotną, która powinna zostać wywołana za każdym razem, gdy zmieni się wartość zewnętrzna, i musi zwrócić odpowiadającą jej funkcję do odsubskrypcji w celu jej usunięcia. Drugi argument, getSnapshot, w tym przypadku funkcja strzałkowa () => state.theme, służy do pobierania aktualnej wartości przez React w momencie, gdy jest to potrzebne.
Dwa komponenty importują ten hook, a żaden z nich nie wie o istnieniu drugiego.
// components/Header.jsx
import { useTheme } from '../store/themeStore';
function Header() {
const theme = useTheme();
return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
}
export default Header;
// components/Sidebar.jsx
import { useTheme } from '../store/themeStore';
function Sidebar() {
const theme = useTheme();
return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
}
export default Sidebar;
Trzeci komponent zawiera przycisk i importuje zarówno ten hook, jak i funkcję setTheme z tego samego magazynu.
// components/ThemeToggleButton.jsx
import { setTheme, useTheme } from '../store/themeStore';
function ThemeToggleButton() {
const theme = useTheme();
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
export default ThemeToggleButton;
Co dzieje się krok po kroku
Każdy z poniższych kroków jest widoczny po uruchomieniu kodu:
- Podczas inicjalizacji
HeaderiSidebarkażdy z nich wywołuje funkcjęuseTheme(). React wywołuje wtedygetSnapshotdla obu komponentów, otrzymuje wartość'light'i renderuje je z tą wartością. - React również wywołuje funkcję rejestracji raz na komponent, dzięki czemu do wspólnego zbioru
listenersdodawane są dwa wewnętrzne callbacki React. Komponenty pozostają niezależnymi elementami w tym zbiorze.
ThemeToggleButton wywołuje setTheme('dark'). Najpierw state jest przypisywany zupełnie nowemu obiektowi, { theme: 'dark' }; jest to zwykły JavaScript, który nie zawiera nic specyficznego dla React.setTheme przechodzi przez zbiór i wywołuje każdego słuchacza. Ponieważ te słuchacze należą do React, ich wywołanie skłania React do sprawdzenia każdego zarejestrowanego komponentu.getSnapshot dla Header, widzi wartość 'dark' zamiast 'light' i ponownie renderuje ten komponent. To samo sprawdzenie powoduje ponowny render Sidebar.Ten setTheme nie jest funkcją zwracaną przez useState. Jest to zwykły, ręcznie napisany kod, który w jednym wywołaniu wykonuje dwie czynności: aktualizuje źródło prawdy, a następnie powiadamia wszystkich słuchaczy.
Nic nie zostało przekazane nigdzie indziej. Cała struktura połączeń składa się wyłącznie z importów. To w przybliżeniu to, co Zustand robi wewnętrznie: mała, spakowana wersja tego samego wzorca rejestrowania i powiadamiania.
Kwestie, które warto znać
getSnapshotmusi zwracać tę samą wartość, gdy nic się nie zmieniło. Bezpieczne jest zwracanie wartości prymitywnej, takiej jakstate.theme; tworzenie nowego obiektu lub tablicy przy każdym wywołaniu sprawia, że React uważa, iż magazyn danych ciągle się zmienia.- Jeśli renderujesz na serwerze,
useSyncExternalStoreprzyjmuje trzeci argument,getServerSnapshot, dla początkowego HTML. Stan na poziomie modułu na serwerze jest również udostępniany pomiędzy zapytaniami, więc unikaj przechowywania danych specyficznych dla użytkownika.
Dlaczego Context nie jest bezpiecznym kompromisem
W przypadku sklepu modułów nie ma nic do dostarczenia. Zmienna state w pliku themeStore.js nigdy nie znajdowała się w drzewie komponentów; komponenty uzyskują do niej dostęp poprzez wywołanie hooka, bez udziału żadnego przodka typu Provider. Otoczenie komponentu App obiektem ThemeProvider dodałoby warstwę, która nic nie przekazuje.
Context ma swoje uzasadnione zastosowania: ograniczenie zasięgu wartości do jednego poddrzewa lub zamiana zależności w testach poprzez wyświetlenie innego Providera. Często zaleca się go jako ostrożną alternatywę dla bibliotek, jednak w tej roli wiąże się z większymi kosztami niż się wydaje. Rozważmy przypadek, gdy jeden context przechowuje zarówno temat, jak i koszyk zakupów.
// context/AppContext.jsx
import { createContext, useState } from 'react';
const AppContext = createContext();
export function AppProvider({ children }) {
const [state, setState] = useState({
theme: 'light',
cart: ['book'],
});
return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}
export default AppContext;
Niewielka etykieta wyświetla z niego jedynie temat.
// components/ThemeLabel.jsx
import { useContext } from 'react';
import AppContext from '../context/AppContext';
function ThemeLabel() {
const { theme } = useContext(AppContext);
return <span>{theme}</span>;
}
export default ThemeLabel;
useContext(AppContext) subskrybuje ThemeLabel do całego obiektu przechowywanego przez Provider, a nie tylko do theme. Gdy cart zmienia się gdzie indziej, Provider otrzymuje nowy obiekt state, a ponieważ referencja się różni, ThemeLabel również jest renderowany ponownie, mimo że theme się nie zmienił i komponent w ogóle nie dotyka cart. Context nie posiada wbudowanej możliwości powiedzenia „obudź mnie tylko wtedy, gdy ta wartość się zmieni”; każdy konsument jest budzony przy każdej zmianie wartości.
Sklep z poprzedniego rozdziału już tego unika. Funkcja useTheme() odczytuje pojedynczą część danych za pomocą metody getSnapshot, a komponent jest renderowany ponownie tylko wtedy, gdy zwrócona wartość tej części się zmieni. Użycie Context jako pośrednika oszczędza jedną instalację, ale powoduje większą liczbę ponownych renderowań. Można to złagodzić, dzieląc stan na kilka wąskich kontekstów, ale wtedy sami budujemy to, co sklep oparty na selektorach dostarcza za darmo.
Gdzie biblioteki do zarządzania stanem naprawdę zdobywają swoje miejsce
Żadne z tych faktów nie czyni Redux, Zustand czy MobX bezużytecznymi. Cztery podane uzasadnienia okazały się błędne; biblioteki natomiast nie. Problem przenoszenia wartości pomiędzy komponentami rozwiązuje Context. Zmianę stanu umożliwia useState. Automatyczne aktualizacje w niepowiązanych komponentach realizuje useSyncExternalStore, który jest dostępny wraz z React. A Context, uważany za kompromis, okazuje się droższy niż mała baza danych do przechowywania wspólnych, często zmieniających się wartości.
Prawdziwy powód do użycia biblioteki pojawia się wtedy, gdy wspólny stan ma wielu niezależnych autorów zmian zamiast głównie odczytujących go komponentów, oraz gdy liczba komponentów korzystających z tego stanu przekracza możliwości utrzymania spójności kilku ręcznie tworzonych baz danych modułowych. Koordynacja aktualizacji, middleware, narzędzia deweloperskie oraz przewidywalne śledzenie zmian na taką skalę to sytuacje, w których dojrzała biblioteka się opłaca i zasługuje na osobne omówienie.
Główne wnioski
- Gdy jedynym problemem jest przekazywanie wartości przez warstwy, które ich nie wykorzystują, skorzystaj z Context, a nie ze sklepu.
useStateto odpowiedni narzędzie do zmiany stanu należącego do jednego komponentu.- Dla stanu udostępnianego między komponentami bez wspólnego rodzica często wystarcza niewielki sklep zbudowany na bazie
useSyncExternalStore. - Jedno szerokie Context przerysowuje wszystkie komponenty, które go używają, przy każdej zmianie; w przypadku często zmieniających się danych lepiej używać wąskich kontekstów lub sklepu opartego na selektorach.
- Zastosuj bibliotekę do zarządzania stanem, gdy masz wielu niezależnych twórców danych i rosnącą liczbę odbiorców, a nie z powodu problemu przekazywania wartości przez wiele poziomów komponentów.
Literatura pokrewna
- Skracanie React Prop-Drilling i bogatych komponentów do rozsądnego rozmiaru — Poznaj siedem konkretnych wzorców refaktoryzacji, które umożliwiają rozbicie rozbudowanych komponentów React poprzez izolację stanu, pobierania danych, uprawnień oraz logiki ładowania, zamiast jedynie dzielenia plików.
- Jak React-Redux decyduje się na ponowne renderowanie: selektory, równość i hooki z type-mi — Przewodnik po mechanizmach wewnętrznych React-Redux: Provider, równość w useSelector, selektory z pamięcą cache, funkcja connect(), hooki z type-mi przy użyciu withTypes() oraz rzadkie przypadki krawędziowe.