Przy porządkowaniu kodu mojego portfolio przed publikacją linter zgłosił ten sam błąd w trzech różnych komponentach: „Calling setState synchronously within an effect can trigger cascading renders”. Kod działał, strona wyglądała dobrze – a mimo to wszystkie trzy przypadki były do poprawy. W tym wpisie pokazuję, co było nie tak, jak to naprawiłem i czego nauczyło mnie to o useEffect.
Do czego naprawdę służy useEffect?
useEffect służy do synchronizacji komponentu z czymś spoza Reacta: timerem, subskrypcją zdarzeń, ręczną zmianą DOM, zewnętrzną biblioteką. Nie służy do wyliczania stanu z innego stanu ani do „ustawienia czegoś zaraz po pierwszym renderze”. Gdy w efekcie od razu wywołujemy setState, React renderuje komponent, uruchamia efekt, a potem musi wyrenderować go jeszcze raz. To niepotrzebna praca, a czasem widoczne mignięcie złej wartości.
Przypadek 1: flaga isMounted
Popularny wzorzec wyglądał tak:
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
Chodzi o to, żeby wiedzieć, czy kod działa już w przeglądarce, i uniknąć błędu hydracji – sytuacji, w której HTML wygenerowany na serwerze różni się od pierwszego renderu po stronie klienta. React ma do tego lepsze narzędzie: useSyncExternalStore, który przyjmuje osobną wartość dla serwera i dla przeglądarki.
const subscribe = () => () => {}
export function useIsClient() {
return useSyncExternalStore(subscribe, () => true, () => false)
}
Przypadek 2: odczyt z localStorage
Wybrany język strony i ustawienia dostępności zapisywałem w localStorage, a przy starcie kopiowałem je do stanu komponentu w efekcie. Problem polegał na tym, że ta sama informacja istniała w dwóch miejscach: w przeglądarce i w stanie Reacta. Rozwiązaniem było potraktowanie localStorage jako zewnętrznego magazynu danych i odczytywanie go przez useSyncExternalStore:
const storedLocale = useLocalStorageItem('site-locale')
const locale = normalizeLocale(storedLocale)
Język jest teraz wyliczany, a nie kopiowany. Jest jedno źródło prawdy, wszystkie komponenty czytające ten klucz są zsynchronizowane – także między kartami przeglądarki – a flaga isMounted stała się zbędna.
Przypadek 3: animacja pisania tekstu
Komponent wyświetlający kolejne słowa litera po literze miał trzy osobne stany: indeks słowa, liczbę widocznych znaków i informację, czy tekst jest właśnie kasowany. Większość zmian odbywała się w timerze, ale przejście do następnego słowa działo się bezpośrednio w efekcie. Głębsza przyczyna była taka, że trzy stany opisywały jedną rzecz – fazę animacji.
Przepisałem go na mały automat stanów z useReducer. Faza jest jedna: pisanie, pauza albo kasowanie, a cała logika przejść siedzi w czystej funkcji:
function advanceRotator(state, { activeWordLength, wordCount }) {
switch (state.phase) {
case 'typing':
// add one character, switch to 'holding' when the word is complete
case 'holding':
return { ...state, phase: 'deleting' }
case 'deleting':
// remove one character, move to the next word at the end
}
}
Efekt robi teraz tylko jedno: ustawia timer, który wywołuje dispatch, i sprząta go w funkcji czyszczącej. Nie da się też uzyskać nielogicznej kombinacji stanów, która wcześniej była teoretycznie możliwa.
Czego się nauczyłem
- Zanim napiszesz useEffect, zapytaj: z czym się synchronizuję? Jeśli odpowiedź brzmi „z niczym spoza Reacta”, prawdopodobnie efekt nie jest potrzebny.
- Nie kopiuj danych do stanu, jeśli możesz je wyliczyć. Jedno źródło prawdy eliminuje całą klasę błędów z rozjechanymi wartościami.
- Kilka powiązanych stanów to sygnał do useReducer. Jawne fazy i czysta funkcja przejść są czytelniejsze i łatwiejsze do przetestowania.
- Dane z zewnętrznych źródeł czytaj przez useSyncExternalStore. Rozwiązuje problem hydracji bez obejść w postaci flag.
- Linter to sojusznik, nie przeszkoda. Reguły z React 19 wyłapały problemy, których nie widać w działaniu strony, a które z czasem prowadzą do trudnych do znalezienia błędów.
Podsumowanie
Wszystkie trzy komponenty działały przed poprawkami – i to jest najciekawsza lekcja. Kod, który „działa”, nie zawsze jest kodem dobrym. Po zmianach komponenty są prostsze, mają mniej stanu, renderują się rzadziej, a ich logika jest jawna. Jeśli Twój linter zgłasza set-state-in-effect, potraktuj to jako zaproszenie do uproszczenia kodu, a nie przeszkodę do wyciszenia.

Dodaj komentarz