Dlaczego funkcja push() powoduje przestarzałość interfejsu React i jak nowe referencje to naprawiają
Zrozum, dlaczego modyfikacja tablic w useState nigdy nie powoduje ponownego renderowania, jak działa sprawdzanie referencji w React oraz kiedy należy używać kopii rozszerzonych i aktualizacji funkcyjnych.
Źródła: React Survival Guide, Part 2: Don’t Mutate! Why `.push()` is Killing Your UI
Niewiele błędów w React jest tak dezorientujących jak interfejs użytkownika, który odmawia aktualizacji, podczas gdy konsola potwierdza, że dane są poprawne. Nie ma błędu ani ostrzeżenia – po prostu ekran pozostaje przy starych wartościach. W przeważającej większości przypadków przyczyną jest mutacja stanu: modyfikowanie tablicy lub obiektu bezpośrednio w miejscu, zamiast go zastąpienia. Ten artykuł wyjaśnia dokładnie, dlaczego React ignoruje takie modyfikacje, pokazuje prosty sposób ich naprawienia oraz omawia formę aktualizacji funkcyjnej, która jest potrzebna, gdy aktualizacje pojawiają się szybko.
Błąd: dane się zmieniają, a ekran nie
Wyobraźmy sobie małą koszykową listę zakupów, listę zadań do wykonania lub tabelę użytkowników. Ostatnim krokiem jest dodanie elementu do tablicy przechowywanej w useState, a pierwsza próba wygląda jak zwykły JavaScript.
const [cart, setCart] = useState(['Coffee']);
const addItem = (newItem) => {
// It's just JavaScript, right?
cart.push(newItem);
console.log("Added! Cart is now:", cart);
};
Po kliknięciu „Dodaj do koszyka” w dzienniku pojawia się ['Kawa', 'Herbata'], więc dane wyglądają poprawnie. Jednak w renderowanym koszyku nadal widnieje tylko „Kawa”. Interfejs pokazuje teraz użytkownikom informacje, które już nie są prawdziwe, co jest gorsze niż awaria, ponieważ nikt tego nie zauważy, dopóki klient o tym nie dowie się. W takiej sytuacji programiści często tracą godzinę na ponowne sprawdzanie pętli i logiki renderowania, które wcale nie były przyczyną problemu.
Zauważ, że ten obsługiwacz w ogóle nie wywołuje funkcji setCart. Nic nie poinformowało Reacta o żadnej zmianie, więc nie miał powodu do renderowania. Bardziej subtelna wersja błędu dodaje setCart(cart) po wywołaniu push(), a ekran nadal się nie zmienia. W następnej sekcji wyjaśniono dlaczego.
React porównuje referencje, a nie zawartość
Kluczowa idea, którą należy sobie uświadomić, jest prosta: React śledzi tożsamość, a nie treść.
Metody takie jak .push(), .pop(), .splice() i .unshift(), czy też przypisanie w stylu user.name = 'Alice', stanowią mutacje. Mówią one JavaScriptowi, aby zmienił wartość przechowywaną w określonym miejscu pamięci. JavaScript robi to natychmiast, dlatego console.log(cart) pokazuje nowy element. React jednak nie obserwuje tej pamięci – jego model aktualizacji zakłada, że stan jest niezmienialny.
Gdy wywołujesz funkcję do ustawiania stanu, React porównuje nową wartość z obecną za pomocą Object.is. W przypadku tablic i obiektów jest to porównanie referencji: sprawdza jedynie, czy chodzi o dokładnie ten sam obiekt co wcześniej, a nie czy jego zawartość się różni. Po wywołaniu push() tablica, którą zwracasz, to ten sam obiekt, który React już posiada. Sprawdzenie wskazuje „niezmieniony”, React pomija ponowną renderizację, a interfejs nadal pokazuje ostatni zapisaną wersję.
To tanie sprawdzenie jest celowe. Porównywanie referencji zajmuje stały czas, niezależnie od wielkości stanu, natomiast głębokie porównanie każdej zagnieżdżonej wartości przy każdej aktualizacji byłoby kosztowne. Kompromis polega na tym, że musisz sygnalizować zmianę, dostarczając nową referencję.
Rozwiązanie: skopiuj, zmień kopię, ustaw kopię
Niezmienniczość wydaje się bardziej wymagająca, niż w rzeczywistości jest. Aby zaktualizować tablicę lub obiekt w stanie, należy pozostawić oryginał nietknięty, utworzyć nowy, który zawiera zmianę, i przekazać go funkcji setter. We współczesnym JavaScriptzie operator rozszerzania (...) jest najszybszym sposobem na to.
Korekta funkcji addItem polega na utworzeniu nowej tablicy, która zawiera stare elementy, a następnie nowy element.
const addItem = (newItem) => {
// The right way (creating a new identity):
setCart([...cart, newItem]); // Create a new array, add newItem at the end.
};
Teraz funkcja setter otrzymuje inną referencję, sprawdzenie za pomocą Object.is zawodzi, a React ponownie renderuje element z dodanym przedmiotem. Ten sam princip odnosi się do innych operacji:
- Usuwanie:
cart.filter(item => item !== target)zamiastsplice(). - Zamienianie:
cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
setUser({ ...user, name: 'Alice' }) zamiast przypisywania wartości do właściwości.[...cart].sort(), albo użyj metod niezmieniających oryginału – toSorted() i toReversed() – tam, gdzie obsługują je Twoje przeglądarki.Pamiętaj, że operacja rozszerzania kopiuje dane tylko na jednym poziomie. Jeśli stan zawiera zagnieżdżone obiekty, każdy poziom, który chcesz zmienić, wymaga osobnej kopii. Aby dowiedzieć się więcej na temat mutacji referencji poza kontekstem stanu w React, w tym struktur zagnieżdżonych, zapoznaj się z artykułem unikanie ukrytych błędów stanu spowodowanych mutacjami referencji w JavaScript.
Korzyści płynące z niezmienności
To nie jest tylko zasada narzucona przez React; dzięki niej kod staje się łatwiejszy do zrozumienia.
- Jasne punkty aktualizacji. Każda zmiana stanu przechodzi przez funkcję setter, więc jest oczywiste, które funkcje modyfikują stan i gdzie.
- Lepsze debugowanie. Ponieważ każda aktualizacja tworzy odrębną wartość, React DevTools oraz inne narzędzia mogą pokazać stan przed i po każdej zmianie.
- Brak ukrytych skutków ubocznych. Ponieważ dane nigdy nie są modyfikowane na miejscu, błąd w jednym komponencie nie może potajemnie uszkodzić tablicy lub obiektu, który jest nadal odczytywany przez inną część aplikacji.
Aktualizacje funkcyjne do szybkich lub nakładających się zmian
Drugim, powiązanym problemem są częste aktualizacje, takie jak szybkie, powtarzane kliknięcia lub kilka asynchronicznych odpowiedzi przychodzących blisko siebie, być może w niewłaściwej kolejności. Każdy obsługiwacz przechowuje wartość stanu z momentu renderowania, w którym został utworzony. Jeśli dwie aktualizacje nastąpią przed ponownym renderowaniem, obie mogą rozpocząć się od tego samego przestarzałego stanu, a jedna zmiana nadpisze drugą. To jest problem przestarzałych zamykań, który bezpośrednio wpływa na sposób tworzenia nowego stanu.
Rozwiązaniem jest przekazanie funkcji do mechanizmu ustawiania stanu. React wywołuje ją z najnowszym, jeszcze niezprzetworzonym stanem, dzięki czemu każda aktualizacja opiera się na wyniku poprzedniej, a nie na przechowanej wartości. Poniższy przykład dodaje partię wygenerowanych elementów. Czyta prevCart.length z argumentu, aby je numerować, a następnie zwraca zupełnie nowy tablicę, która łączy poprzednie elementy z nowymi.
// A very fast counter:
const addManyItems = (count) => {
// Good: Uses spread to be immutable
// Better: Uses functional update for consistency
setCart(prevCart => {
// We are generating new array content:
const newItems = Array.from({ length: count }, (_, i) => `Item ${prevCart.length + i + 1}`);
// Spread operator for the final, clean, new identity:
return [...prevCart, ...newItems];
});
};
Tutaj współpracują dwie rzeczy. Operacja rozszerzania nadal tworzy nowy odwołanie, co powoduje ponowną renderizację w React. Funkcja aktualizująca gwarantuje, że tablica bazowa jest najnowsza, co zapobiega utracie aktualizacji. Dobrym nawykiem jest używanie formy funkcyjnej zawsze wtedy, gdy następny stan jest obliczany na podstawie poprzedniego stanu.
Podsumowanie
- Mutacja zmienia dane, ale nie odwołanie, więc sprawdzenie
Object.isw React dochodzi do wniosku, że nic się nie zmieniło, i pomija renderizację. - Gdy interfejs użytkownika jest w tyle za danymi, najpierw sprawdź użycie metod
.push(),.unshift(),.splice()lubstate.property = valuew odniesieniu do stanu. - Stwórz nową tablicę lub obiekt za pomocą operacji rozszerzania,
maplubfilter, a następnie skopiuj każdy poziom wewnątrz, który zmieniasz.
Rozwiązanie zwykle wymaga tylko jednej linijki kodu i jest różnicą pomiędzy interfejsem, któremu użytkownicy mogą ufać, a takim, który w tle pokazuje przestarzałe informacje.
Pozycje powiązane
- Prop Drilling nie jest powodem do instalacji Redux lub Zustand — Przetestuj cztery powszechne powody dodawania biblioteki do zarządzania stanem w kontekście działającego kodu React: Context do przekazywania wartości propów, useState, useSyncExternalStore oraz koszt ponownego renderowania Context.
- Przemyślenie o stanie w React: gdzie naprawdę powinny znajdować się twoje dane — Ten artykuł wyjaśnia, jak zmniejszyć liczba błędów w React poprzez przenoszenie stanu do URL, DOM lub wartości pochodnych zamiast nadmiernego używania useState.
- Klucze w React jako identyfikator komponentu: zachowywanie i resetowanie stanu — Dowiedz się, dlaczego klucze w React decydują o tym, która instancja komponentu przeżyje renderowanie, jak klucze indeksowe powodują wyciek stanu między wierszami oraz jak użyć klucza do resetowania formularza.
- 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.
- Przemyślenie stanu w React: gdzie powinny faktycznie znajdować się twoje dane — Ten artykuł wyjaśnia, jak zmniejszyć błędy w React poprzez umieszczanie stanu w adresie URL, DOM lub wartościach pochodnych zamiast nadmiernego używania useState.
- Gdzie useState przechowuje swoją wartość: elementy React versus Fibers — Dlaczego komponent funkcyjny React zapomina o wszystkim pomiędzy wywołaniami, dlaczego elementy nie mogą przechowywać stanu oraz jak pole memoizedState w strukturze fiber utrzymuje wartości useState przy życiu.
- Najpierw pomiar: dlaczego wczesna optymalizacja utrudnia uruchamianie aplikacji Next.js — Zobacz, jak przedwczesna memoizacja, szerokie granice klienta oraz warstwowe pamięci cache zwiększają złożoność aplikacji Next.js, oraz jak podejście oparte na pomiarach zapewnia im szybkość.