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.
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:
- React renderuje komponent, używając nowych właściwości wraz z nadal przestarzałym stanem.
setState().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:
- React ponownie renderuje używając nowego
organizationId. - Pierwszy efekt pobiera listę zespołów, a następnie aktualizuje zarówno
teams, jak iselectedTeamId. - React ponownie renderuje.
- Drugi efekt zauważa zaktualizowany
selectedTeamIdi pobiera powiązane projekty. - React renderuje jeszcze raz.
- Trzeci efekt bierze pod uwagę nowy
selectedProjectIdi 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:
- Agent klikuje w Ticket #101. Wyświetla się Prośba A.
- Zanim Prośba A zostanie rozwiązana, agent klikuje w Ticket #102. Wyświetla się Prośba B.
- Prośba B zostaje rozwiązana pierwsza, więc interfejs teraz pokazuje Ticket #102.
- W końcu Prośba A zostaje rozwiązana i wywołuje
setTicket(ticket101). - 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,IntersectionObserverlubmatchMedia
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
- Budowanie modelu mentalnego dla React: Reconciliation, State i Hooks — Poznaj logikę stojącą za podstawowymi koncepcjami React – reconciliation, komponenty, props, state i hooks – aby rozwijać intuicję zamiast zapamiętywać API.
- Narzędziówka do używanych hooki dla każdego nowego projektu React — Odkryj starannie wyselekcjonowaną kolekcję dostosowanych hooki React – obejmujących przechowywanie danych, opóźnianie wywołań, obsługę kliknięć i pobieranie danych – które eliminują powtarzalny kod szablonowy w nowych projektach.