Strona główna / Artykuły / Przestań synchronizować stan za pomocą useEffect: Bezpieczniejszy wzorzec w React

Przestań synchronizować stan za pomocą useEffect: Bezpieczniejszy wzorzec w React

Dowiedz się, dlaczego używanie useEffect do synchronizacji stanu pochodnego powoduje sytuacje wyścigu i dodatkowe renderowania, oraz jak zastąpić to wyprowadzaniem stanu w czasie renderowania i użyciem atrybutu key.

2523 słów

Jak traktowanie useEffect jako mechanizmu do synchronizacji stanu powoduje pętle renderowania, sytuacje wyścigu oraz dziwne błędy interfejsu.

Na twoim biurku pojawia się zgłoszenie techniczne oznaczone jako pilne. Klient donosi, że podczas przechodzenia między kontami na panelu zespołu dziennik aktywności czasami pokazuje wydarzenia należące do konta, które oglądał trzydzieści sekund wcześniej.

Badasz kod. Nie ma tu nic nietypowego — żadnej warstwy WebSocket, żadnych wątków roboczych, tylko dość standardowy widok typu master-detail w React.

Testując to lokalnie, klikasz kolejno przez różne konta. Pierwsze dziewięćdziesiąt dziewięć przełączeń działa poprawnie. Następnie, przy setnym próbie z włączoną ograniczoną prędkością sieci, coś psuje wygląd interfejsu: na krótki moment poziom abonamentowy wcześniej wybranego użytkownika pojawia się w karcie profilu nowo wybranego użytkownika, zanim system to poprawia.

Korzeniem tego problemu jest wzorzec, który wydaje się całkowicie nieszkodliwy:

useEffect(() => {
  if (selectedUserId) {
    fetchUserData(selectedUserId).then((data) => {
      setUserProfile(data);
    });
  }
}, [selectedUserId]);

To jedno nawykanie – uciekanie się do useEffect, aby utrzymać stan wewnętrznego komponentu zgodny z parametrami wejściowymi lub innym stanem – powoduje w nowoczesnym kodzie frontendu więcej subtelnych błędów, problemy z wydajnością oraz trudności strukturalne niż prawie jakikolwiek inny wzorzec.

1. Błąd związany z cyklem życia komponentu: Dlaczego deweloperzy preferują useEffect

Gdy w React 16.8 pojawiły się Hooks, inżynierowie z doświadczeniem w komponentach klasowych często traktowali useEffect jako zamiennik componentDidMount, componentDidUpdate oraz componentWillUnmount połączonych w jedną interfejs API.

To założenie doprowadziło później do prawdziwego zamieszania.

Komponenty klasowe zachęcały do stylu imperatywnego: gdy zmieniła się właściwość, trzeba było ręcznie wywołać this.setState() w metodzie componentDidUpdate, aby ponownie obliczyć wszystko, co z niej wynikało.

Przechodząc na komponenty funkcyjne, wielu programistów zachowało ten sam imperatywny nawyk, zakładając w zasadzie, że za każdym razem, gdy zmienia się jakaś właściwość, ich zadaniem jest wyraźne wprowadzenie odpowiedniej aktualizacji do lokalnego stanu.

Problem polega na tym, że React jest zasadniczo deklaratywny i oparty na stanie. Pisanie funkcji useEffect, której jedynym celem jest aktualizacja inniej lokalnej zmiennej stanu, faktycznie zmusza React do przeprowadzenia dwóch pełnych procesów renderowania zamiast jednego.

Oto sekwencja, która ma miejsce:

  1. React renderuje komponent, używając nowych właściwości wraz z nadal przestarzałym stanem.
  • To renderowanie jest zapisywane w DOM-ie, a przeglądarka je rysuje.
  • Efekt jest uruchamiany i wywołuje metodę setState().
  • React umieszcza w kolejce drugie renderowanie odzwierciedlające zaktualizowany stan.
  • Weźmy ten przykład:

    function UserBillingSummary({
      plan,
      addonCount
    }: {
      plan: string;
      addonCount: number;
    }) {
      const [totalCost, setTotalCost] = useState(0);
    
      useEffect(() => {
        const base = plan === 'enterprise' ? 499 : 99;
        setTotalCost(base + addonCount * 25);
      }, [plan, addonCount]);  return <div>Total: ${totalCost} / month</div>;
    }
    

    Tutaj w ogóle nie ma potrzeby osobnej zmiennej stanu — totalCost można w pełni obliczyć na podstawie plan i addonCount.

    Innymi słowy, komponent wykonywa niepotrzebną pracę tylko po to, by uzyskać wartość, którą można było już obliczyć podczas początkowego renderowania.

    W tym przejściowym kadrze użytkownicy mogą na chwilę zobaczyć na ekranie niezgodne liczby. A jeśli jakaś logika układu zależy od tej obliczonej wartości, przeglądarka może być również zmuszona ponownie wykonać operacje układu i rysowania, których nie powinna była powtarzać.

    2. Efekt domina: łańcuchy zależności kaskadowych

    Koszt podwójnego renderowania znacznie rośnie, gdy kilka efektów zaczyna polegać na wynikach siebie nawzajem.

    Załóżmy panel filtrowania wieloetapowego w interfejsie analitycznym:

    function AnalyticsFilters({
      organizationId
    }: {
      organizationId: string;
    }) {
      const [teams, setTeams] = useState<Team[]>([]);
      const [selectedTeamId, setSelectedTeamId] = useState<string>('');
      const [projects, setProjects] = useState<Project[]>([]);
      const [selectedProjectId, setSelectedProjectId] = useState<string>('');
    
      useEffect(() => {
        fetchTeams(organizationId).then((res) => {
          setTeams(res);
          setSelectedTeamId(res[0]?.id || '');
        });
      }, [organizationId]);  useEffect(() => {
        if (selectedTeamId) {
          fetchProjects(selectedTeamId).then((res) => {
            setProjects(res);
            setSelectedProjectId(res[0]?.id || '');
          });
        }
      }, [selectedTeamId]);  useEffect(() => {
        if (selectedProjectId) {
          logAnalyticsFilterChange(selectedProjectId);
        }
      }, [selectedProjectId]);  return (
        <div className="filter-bar">
          {/* Filter UI */}
        </div>
      );
    }
    

    Zobacz, co się dzieje w momencie zmiany organizationId:

    1. React ponownie renderuje używając nowego organizationId.
    2. Pierwszy efekt pobiera listę zespołów, a następnie aktualizuje zarówno teams, jak i selectedTeamId.
    3. React ponownie renderuje.
    4. Drugi efekt zauważa zaktualizowany selectedTeamId i pobiera powiązane projekty.
    5. React renderuje jeszcze raz.
    6. Trzeci efekt bierze pod uwagę nowy selectedProjectId i rejestruje tę zmianę.

    To, co zaczęło się jako jedna aktualizacja właściwości, przerodziło się teraz w łańcuch zmian stanu i wykonywania efektów.

    Gdy aplikacja rośnie, takie łańcuchy stają się naprawdę trudne do śledzenia. Jeśli odpowiedzi sieci przychodzą w niewłaściwej kolejności lub jeśli jedna z nich zwraca wartość pustą z powodu problemów z uprawnieniami lub innego rzadkiego przypadku, interfejs może znaleźć się w niespójnym stanie bez wyświetlenia żadnego oczywistego błędu.

    Prawdziwy problem nie polega tylko na większej liczbie operacji renderowania — chodzi o to, że komponent w tle przekształcił się w miniaturową asynchroniczną maszynę stanową, której nikt specjalnie tak nie zaprojektował.

    3. Duch warunków wyścigowych w asynchroniczności

    Niezarządzane wywołania asynchroniczne wewnątrz useEffect to kolejne częste źródło fałszywych danych pojawiających się w aplikacjach jednostronicowych.

    Wyobraź sobie pracownika obsługi klienta szybko klikającego w różne wiersze tabeli z ticketami:

    function TicketDetailView({
      ticketId
    }: {
      ticketId: string;
    }) {
      const [ticket, setTicket] = useState<TicketData | null>(null);
      const [loading, setLoading] = useState(true);
    
      useEffect(() => {
        setLoading(true);    api.getTicket(ticketId).then((data) => {
          setTicket(data);
          setLoading(false);
        });
      }, [ticketId]);  if (loading) return <div>Loading ticket...</div>;  return <TicketDetails ticket={ticket} />;
    }
    

    Oto sekwencja zdarzeń, która psuje ten komponent:

    1. Agent klikuje w Ticket #101. Wyświetla się Prośba A.
    2. Zanim Prośba A zostanie rozwiązana, agent klikuje w Ticket #102. Wyświetla się Prośba B.
    3. Prośba B zostaje rozwiązana pierwsza, więc interfejs teraz pokazuje Ticket #102.
    4. W końcu Prośba A zostaje rozwiązana i wywołuje setTicket(ticket101).
    5. Pasek boczny nadal podświetla Ticket #102, ale panel szczegółów pokazuje teraz Ticket #101.

    React nie jest tu winny. Prawdziwym błędem jest to, że komponent pozwala przestarzałej prośbie nadpisać stan po tym, jak użytkownik już przeniósł się gdzie indziej.

    Jeśli pobierasz dane wewnątrz efektu bez żadnej biblioteki pomocniczej, musisz temu zapobiec poprzez ręczne czyszczenie.

    useEffect(() => {
      let isCurrent = true;
    
      setLoading(true);  api.getTicket(ticketId)
        .then((data) => {
          if (isCurrent) {
            setTicket(data);
            setLoading(false);
          }
        })
        .catch((err) => {
          if (isCurrent) {
            handleTicketError(err);
          }
        });  return () => {
        isCurrent = false;
      };
    }, [ticketId]);
    

    Lepszą opcją, jeśli Twój klient API to obsługuje, jest AbortController. Zamiast po prostu odrzucać spóźnioną odpowiedź, można faktycznie anulować trwającą prośbę, zanim zdąży zostać rozwiązana.

    4. Czysta alternatywa: stan pochodny podczas renderowania

    Często najprostszym rozwiązaniem problemów z synchronizacją jest unikanie synchronizacji stanu od samego początku.

    W wielu przypadkach, gdy programiści instynktownie uciekają się do useState w połączeniu z useEffect, wartość, którą próbują przechować, już istnieje gdzieś w aktualnych propach lub stanie rodzica.

    Zamiast kopiować tę wartość do lokalnego stanu, można ją po prostu obliczyć bezpośrednio podczas renderowania.

    Oto problematyczny wzorzec:

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const [discountPercent, setDiscountPercent] = useState(0);
      const [subtotal, setSubtotal] = useState(0);
      const [finalTotal, setFinalTotal] = useState(0);
    
      useEffect(() => {
        const rawSum = items.reduce(
          (acc, item) => acc + item.price * item.quantity,
          0
        );    setSubtotal(rawSum);
      }, [items]);  useEffect(() => {
        const discount = calculateDiscount(discountCode);
        setDiscountPercent(discount);
      }, [discountCode]);  useEffect(() => {
        setFinalTotal(
          subtotal - subtotal * (discountPercent / 100)
        );
      }, [subtotal, discountPercent]);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    A teraz porównaj to z wersją opartą na stanie pochodnym:

    function OrderSummary({
      items,
      discountCode
    }: OrderSummaryProps) {
      const subtotal = items.reduce(
        (acc, item) => acc + item.price * item.quantity,
        0
      );
    
      const discountPercent = calculateDiscount(discountCode);  const finalTotal =
        subtotal - subtotal * (discountPercent / 100);  return (
        <SummaryView
          subtotal={subtotal}
          total={finalTotal}
        />
      );
    }
    

    Kontrast ma znaczenie. Nie ma duplikowanego stanu, żadnego mechanizmu odpowiedzialnego za synchronizację wartości, ani okna, w którym finalTotal mógłby stracić zgodność z subtotal i discountPercent.

    Samych propów przez cały czas stanowi jedyny źródło prawdy.

    Gdy obliczenie jest rzeczywiście kosztowne, useMemo umożliwia przechowywanie wyniku pomiędzy renderami:

    const filteredTransactions = useMemo(() => {
      return rawTransactions.filter((tx) => {
        return (
          tx.amount >= minThreshold &&
          tx.category === activeCategory
        );
      });
    }, [rawTransactions, minThreshold, activeCategory]);
    

    Kluczowym punktem jest to, że useMemo istnieje wyłącznie po to, by zapamiętywać wyniki obliczeń — nie służy do synchronizacji dwóch oddzielnych stanów.

    5. Szybkie resetowanie stanu za pomocą propa key

    Podobna pułapka pojawia się, gdy formularz edytowalny musi resetować swoje pola za każdym razem, gdy zmienia się edytowana entyty.

    Zwykły instynkt polega na następującym podejściu:

    function EditUserModal({
      user
    }: {
      user: UserData;
    }) {
      const [name, setName] = useState(user.name);
      const [role, setRole] = useState(user.role);
    
      useEffect(() => {
        setName(user.name);
        setRole(user.role);
      }, [user.id]);  return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

    Taki podejście może powodować widoczny błysk, gdy dane poprzedniej rekordu pozostają na ekranie przez jedną iterację renderowania, zanim zostanie uruchomiony efekt i zaktualizowane pola wprowadzania.

    Gorzej jeszcze – może w tajemnicy nadpisać to, co użytkownik wpisywał, jeśli nowe dane przyjdą w trakcie edycji.

    React ma już wbudowane, deklaratywne rozwiązanie na ten problem: atrybut key.

    W komponencie nadrzędnym:

    function UserAdminPage() {
      const [selectedUser, setSelectedUser] =
        useState<UserData | null>(null);
    
      return (
        <div>
          <UserList onSelectUser={setSelectedUser} />      {selectedUser && (
            <EditUserForm
              key={selectedUser.id}
              initialUser={selectedUser}
            />
          )}
        </div>
      );
    }
    

    Stan lokalny komponentu potomnego pozostaje wtedy prosty, bez niczego do skonsolidowania:

    function EditUserForm({
      initialUser
    }: {
      initialUser: UserData;
    }) {
      const [name, setName] = useState(initialUser.name);
      const [role, setRole] = useState(initialUser.role);
    
      return (
        <form>
          <input
            value={name}
            onChange={(e) => setName(e.target.value)}
          />      <select
            value={role}
            onChange={(e) => setRole(e.target.value)}
          />
        </form>
      );
    }
    

    Gdy key zmienia się z user-1 na user-2, React nie próbuje aktualizować istniejącego komponentu – odrzuca go i montuje zupełnie nową instancję, której stan jest inicjalizowany na podstawie nowych danych użytkownika.

    W ogóle nie jest potrzebny żaden efekt synchronizacji.

    6. Gdzie faktycznie powinien znajdować się kod: obsługi wydarzeń vs. efekty

    Korzystnym modelem myślowym jest następujący: efekty służą do synchronizacji komponentu z czymś poza React, natomiast obsługi wydarzeń są przeznaczone do reagowania na działania użytkownika.

    Załóżmy, że musimy wysłać zdarzenie analityczne i pokazać komunikat potwierdzenia za każdym razem, gdy ktoś kliknie „Zatwierdź zamówienie”.

    Jeden ze sposobów na to jest następujący:

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const [submitted, setSubmitted] = useState(false);
    
      useEffect(() => {
        if (submitted) {
          analytics.track('order_submitted', {
            orderId
          });      showToast('Order placed successfully!');
        }
      }, [submitted, orderId]);  return (
        <button onClick={() => setSubmitted(true)}>
          Place Order
        </button>
      );
    }
    

    Problem polega na tym, że w ten sposób oddziela się mechanizm wyzwalania od samej akcji — kliknięcie ustawia flagę, a efekt reaguje na tę flagę później.

    Czystsze podejście polega na umieszczeniu wszystkiego wewnątrz samej obsługi wydarzenia:

    function CheckoutButton({
      orderId
    }: {
      orderId: string;
    }) {
      const handlePlaceOrder = async () => {
        await submitOrderApi(orderId);
    
        analytics.track('order_submitted', {
          orderId
        });    showToast('Order placed successfully!');
      };  return (
        <button onClick={handlePlaceOrder}>
          Place Order
        </button>
      );
    }
    

    Teraz przyczynowość jest jasna: użytkownik kliknie, zamówienie zostanie wysłane, a kolejne działania uruchomią się natychmiast jako część tego samego zdarzenia. Nie ma żadnej pośredniej zmiany stanu, którą można by wykryć i na którą można by zareagować.

    7. Kiedy useEffect jest rzeczywiście uzasadniony?

    To wszystko nie oznacza, że sam useEffect jest błędny.

    Problemy pojawiają się, gdy traktuje się go jako uniwersalne narzędzie do przekazywania danych pomiędzy różnymi elementami stanu React.

    Jego prawdziwym zadaniem jest utrzymywanie synchronizacji komponentu z czymś, co znajduje się poza własnym modelem renderowania React.

    To „coś poza React” zazwyczaj należy do kategorii takich jak:

    • Natywne API przeglądarki, takie jak window.addEventListener, IntersectionObserver lub matchMedia
  • Biblioteki zewnętrzne typu imperative, takie jak Mapbox, Chart.js czy SDK do odtwarzania wideo
  • Połączenia w czasie rzeczywistym, takie jak WebSockets lub Server-Sent Events
  • Direktna manipulacja DOM, np. aktualizacja document.title
  • Śledzenie szerokości obszaru widoku przeglądarki przy zmianie rozmiaru jest dobrym przykładem uzasadnionego zastosowania:

    function useWindowWidth() {
      const [width, setWidth] = useState(
        () => window.innerWidth
      );
    
      useEffect(() => {
        const handleResize = () => {
          setWidth(window.innerWidth);
        };    window.addEventListener(
          'resize',
          handleResize
        );    return () => {
          window.removeEventListener(
            'resize',
            handleResize
          );
        };
      }, []);  return width;
    }
    

    W tym przypadku efektem jest wykonywanie czegoś, czego sama renderizacja nie może osiągnąć: ustawienie subskrypcji na zdarzenie przeglądarki i jej usunięcie podczas czyszczenia.

    To właśnie taka praca została zaprojektowana do wykonywania przez useEffect.

    Zasady architektoniczne, których teraz przestrzegam

    Gdy panel sterowania lub aplikacja oparta na React zaczyna działać wolno, niestabilnie lub jest pełna błędów związanych z czasem wykonywania, które trudno odtworzyć, pierwszym miejscem do sprawdzenia jest sposób używania useEffect w całym kodzie.

    Cztery podstawowe zasady pomagają rozwiązać większość problemów.

    1. Obliczaj wartości podczas renderowania. Wszystko, co można wywnioskować z propów lub istniejącego stanu, powinno być obliczane bezpośrednio w ciele renderowania. Używaj useMemo tylko wtedy, gdy takie obliczenia są rzeczywiście kosztowne.

    2. Umieszczaj logikę uruchamianą przez użytkownika w obsługiwaczach zdarzeń. Gdy coś się dzieje w wyniku kliknięcia, naciśnięcia klawisza, wyboru lub wysłania danych, ta logika powinna znajdować się bezpośrednio obok zdarzenia, które ją spowodowało, a nie być rozproszona w osobnym efekcie.

    3. Czysto resetuj stan za pomocą propu key. Przy przełączaniu się między elementami powinna powstawać zupełnie nowa instancja komponentu – pozwól Reactowi ponownie załadować ten komponent zamiast ręcznie synchronizować każde pojedyncze pole.

    4. Zachowaj useEffect wyłącznie do rzeczywistej synchronizacji z zewnątrz. Zdarzenia przeglądarki, subskrypcje, połączenia WebSocket oraz integracje z bibliotekami imperatywnymi to właściwe obszary zastosowania efektów.

    Celem nie jest całkowite usunięcie useEffect z twojej bazy kodu.

    Celem jest przestanie używania go jako nieformalnego mechanizmu do przenoszenia wartości pomiędzy różnymi elementami stanu React.

    Gdy wartości pochodne są traktowane jako takie, działania użytkownika są obsługiwane jako zdarzenia, a tylko prawdziwe systemy zewnętrzne są łączone za pomocą efektów, komponenty React stają się znacznie łatwiejsze do zrozumienia.

    Ponadto duża część tajemniczych błędów, które pojawiają się dopiero po dziesiątkach kliknięć, przy wolnym połączeniu sieciowym lub wyłącznie w środowisku produkcyjnym, staje się o wiele łatwiejsza do zapobiegania od samego początku.

    Literatura pokrewna

  • Włączanie wspierania poza internetem w aplikacjach webowych z użyciem Service Workers — Dowiedz się, jak wykorzystać Service Workers oraz Cache API, aby strona internetowa ładowała się natychmiastowo i nadal funkcjonowała nawet bez połączenia z internetem.
  • Wyjaśnienie renderowania w React: aktualizacje stanu do pikseli na ekranie — Dowiedz się, w jaki sposób fazy renderowania, porównywania i zapisu w React łączą się z procesami układania, malowania i kompozycji w przeglądarce w celu tworzenia pikseli.
  • Naprawianie sytuacji wyścigowych: dlaczego debouncing nie rozwiązuje problemów w interfejsach wyszukiwania — Dowiedz się, dlaczego sam debouncing nie może zapobiec nadpisywaniu nowego stanu interfejsu przez przestarzałe odpowiedzi API, oraz poznaj cztery praktyczne rozwiązania umożliwiające kontrolowanie kolejności zapytań.
  • Dlaczego callbacki useEffect-u u React-a nigdy nie powinny być funkcjami asynchronicznymi — Dowiedz się, dlaczego zwracanie funkcji asynchronicznej z useEffect narusza zasady czyszczenia stanu w React, oraz przeczytaj o czterech poprawnych sposobach bezpiecznego obsługi logiki asynchronicznej.