Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP6 min czytania1280 słów

Ź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) zamiast splice().
  • Zamienianie: cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
  • aktualizacja obiektu: setUser({ ...user, name: 'Alice' }) zamiast przypisywania wartości do właściwości.
  • sortowanie lub odwracanie kolejności: najpierw skopiuj zbiór za pomocą [...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.

    1. Jasne punkty aktualizacji. Każda zmiana stanu przechodzi przez funkcję setter, więc jest oczywiste, które funkcje modyfikują stan i gdzie.
    2. 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.
    3. 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.is w 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() lub state.property = value w odniesieniu do stanu.
    • Stwórz nową tablicę lub obiekt za pomocą operacji rozszerzania, map lub filter, a następnie skopiuj każdy poziom wewnątrz, który zmieniasz.
  • Niech funkcjonalny aktualizator będzie używany, gdy nowy stan zależy od starego stanu, szczególnie przy szybkich lub asynchronicznych aktualizacjach.
  • 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