Pokażę proces, którego używam przy diagnozowaniu ponownych renderowań: React DevTools Profiler, , typowe przyczyny i poprawki. Bez rytuału z losowym useMemo w każdym pliku.
Czy ponowne renderowanie w React to zawsze problem?
Nie każde ponowne renderowanie jest problemem, ponieważ to wywołanie funkcji komponentu i porównanie nowego wyniku z poprzednim. Nie oznacza automatycznie zmiany DOM ani ponownego malowania ekranu, bo React wykonuje commit tylko tam, gdzie wynik faktycznie się zmienił. Ostateczny koszt zależy od urządzenia, danych i drzewa komponentów, więc sama liczba renderów nie pokaże nam precyzyjnie skali problemu lub czy problem rzeczywiście istnieje.
Ponowne renderowanie staje się problemem, gdy:
- Komponent jest ciężki w obliczeniach (filtrowanie 10 000 elementów, parsowanie dużych stringów, operacje na obrazach).
- Komponent ma wiele dzieci, a żadne z nich nie jest zoptymalizowane.
- Renderowania kaskadują — zmiana w rodzicu ponownie renderuje pół drzewa.
- Interakcje użytkownika są opóźnione — po kliknięciu przycisku odpowiedź przychodzi z 200-milisekundowym poślizgiem.
Jeśli strona działa szybko, nie optymalizuj prewencyjnie. Włączony i poprawnie działający automatycznie pokrywa wiele przypadków ręcznej memoizacji, ale nie zatrzyma aktualizacji własnego stanu, zmian Contextu, pętli useEffect ani skutków błędnych key. Sprawdziłem, co kompilator robi z useMemo i useCallback w osobnym artykule.
Narzędzia do debugowania ponownych renderowań w React
React DevTools Profiler
To pierwsza rzecz, po którą sięgam. Instalacja: rozszerzenie „React Developer Tools" do Chrome/Firefox.
Po zainstalowaniu, w DevTools pojawia się zakładka „⚛️ Profiler". Przebieg:
- Otwórz Profiler.
- Kliknij zielony „Record".
- Wykonaj interakcję, która wydaje się wolna (kliknij przycisk, wpisz coś w pole).
- Zatrzymaj nagrywanie.
Dostaniesz wykres płomieniowy z listą renderów posortowanych po czasie. Dla każdego komponentu widzisz:
- Czas renderowania (kolorem: zielony = szybki, żółty = OK, czerwony = wolny).
- Dlaczego się renderował (w zakładce „Why did this render?" — zmiana propsów, stanu albo hooka).
- Liczbę renderów w zadanym czasie.
Pytanie, które zadaję w profilerze: czy ten komponent powinien się renderować przy tej interakcji? Jeśli użytkownik kliknął przycisk w stopce, a renderuje się cały układ strony, profiler pokaże łańcuch zdarzeń.
why-did-you-render
Dla głębszego debugowania instalacja @welldone-software/why-did-you-render:
Przy każdym ponownym renderowaniu w konsoli pojawia się komunikat „Different reason" z dokładnym wskazaniem, który prop się zmienił i jak:
Ten ostatni przypadek — „different objects with the same keys" — to najczęstszy problem. Przechodzę do niego.
React DevTools Highlight Updates
W DevTools → Profiler → Settings → „Highlight updates when components render". Każdy komponent, który się renderuje, dostaje krótkie zielone/żółte/czerwone obramowanie. To wizualna informacja zwrotna, która pokazuje aktywność Reacta na bieżąco.
Idealne do szybkiego sprawdzenia — „klikam przycisk, co się zaktualizowało?". Jeśli cały układ strony miga, masz kaskadę.
react-scan: podświetlanie renderów bez konfiguracji
Nowszy gracz w tym zestawie i najszybszy sposób na pierwszy rzut oka: react-scan podświetla renderujące się komponenty bez instalowania czegokolwiek w projekcie:
Działa też jako tag <script> albo pakiet importowany przed Reactem. W odróżnieniu od Highlight Updates z DevTools pokazuje liczniki renderów bezpośrednio na elementach i agreguje statystyki, więc dobrze nadaje się do szybkiego audytu cudzego projektu — uruchamiasz go na działającej aplikacji i od razu widzisz, które obszary „grzeją się" przy interakcjach. Do precyzyjnej diagnozy przyczyn i tak wrócisz do Profilera i why-did-you-render, ale jako pierwsza warstwa rozpoznania react-scan jest nie do pobicia.
Typowe przyczyny niepotrzebnych renderów w React
Przyczyna 1: obiekty i tablice w propsach
Każdy render Parent tworzy nowy obiekt { theme: 'dark', locale: 'pl' }. JavaScript traktuje go jako różny od poprzedniego (Object.is zwraca false) i to psuje dwie rzeczy naraz. W pierwszym przypadku, gdy komponent ExpensiveChild jest owinięty w React.memo, memoizacja staje się całkowicie bezużyteczna. Porównanie propsów przy każdym renderze wykryje „zmianę”, co wymusi ponowne renderowanie komponentu. W drugim scenariuszu – nawet bez użycia memo – każdy useMemo, useEffect czy useCallback wewnątrz komponentu potomnego, który ma config w tablicy zależności, będzie bezpotrzebnie uruchamiany na nowo przy każdym renderze rodzica.
Warto tu doprecyzować, że bez React.memo dziecko renderuje się ponownie przy każdym renderze rodzica niezależnie od propsów, więc stabilna referencja sama w sobie tego nie zatrzyma. Stabilizacja referencji ma sens w parze z memoizacją dziecka albo gdy obiekt trafia do tablic zależności hooków.
Lub, gdy wartość zależy od stanu:
Z React Compilerem ten problem często znika automatycznie. Bez — trzeba pamiętać o stabilności referencji.
Przyczyna 2: funkcje tworzone w miejscu i przekazywane w propsach
Dokładnie ten sam problem — każdy render tworzy nową funkcję. Jeśli ExpensiveButton jest zmemoizowany (React.memo), to i tak renderuje się ponownie, bo onClick jest „inny".
Przyczyna 3: Context ze zmieniającymi się wartościami
Context propaguje wszystkie zmiany do wszystkich konsumentów, nawet jeśli konsument nie używa zmienionego fragmentu.
Przy każdym renderze App, wartość value to nowy obiekt. To wystarcza, aby React powiadomił wszystkich konsumentów tego Contextu o zmianie, nawet jeśli user ani theme faktycznie się nie zmieniły. Nie oznacza to, że każdy komponent pod Providerem jest konsumentem: komponenty, które nie wywołują useContext(AppContext), mogą uniknąć tej aktualizacji, jeśli nie renderują się ponownie z innych powodów.
Teraz zmiana użytkownika renderuje ponownie tylko konsumentów UserContext, nie ThemeContext.
Dla bardziej skomplikowanych przypadków sprawdź zewnętrzny magazyn stanu z selektorami (np. Zustand) — komponent subskrybuje tylko wycinek stanu, więc renderuje się ponownie dopiero wtedy, gdy ten konkretny wycinek się zmieni. Context tej granulacji nie ma i mieć nie będzie.
Przyczyna 4: nowy obiekt w stanie
Wygląda niewinnie i w tym prostym przykładzie nawet działa poprawnie. Problem ujawnia się przez mechanizm pomijania aktualizacji: gdy wywołasz setState z wartością identyczną według Object.is, React zwykle pomija aktualizację; funkcja komponentu może zostać dodatkowo wywołana, ale jej wynik zostanie odrzucony. Prymitywy z tego korzystają — setIsOn(false), gdy isOn już jest false, nie powoduje commitu. setState({ isOn: false }) tworzy zaś zawsze nową referencję, więc React widzi zmianę, nawet jeśli wartości w środku są takie same. Drugi skutek: obiekt stanu przekazywany w dół jako props rozbraja React.memo dzieci przy każdej aktualizacji — także tej, która zmieniła zupełnie inne pole obiektu.
Pamiętaj, jeśli stan to jedna flaga albo jedna wartość, użyj prymitywu, bo React może wtedy bezkosztowo pominąć zbędną aktualizację. Obiekt w stanie ma sens tylko wtedy, gdy wartości są logicznie ze sobą powiązane i zmieniają się razem (np. współrzędne, formularz).
Przyczyna 5: kaskada useEffect
Trzy ponowne renderowania za każdą zmianę filters. Ten „potok danych przez efekty" to klasyczny antywzorzec: stan pochodny nie potrzebuje własnego useState ani useEffect — wystarczy policzyć go w renderze. Pokrewny problem, czyli pobieranie danych łańcuchami efektów, rozbieram w przewodniku o React Query, SWR i useEffect.
Jeden render zamiast trzech.
Przyczyna 6: zły key w liście
Gdy dodajesz nowy element na początek listy, indeks każdego istniejącego elementu się przesuwa. React nie odmontowuje wtedy całej listy: zachowuje key={0}, key={1} itd., ale przypisuje istniejące instancje komponentów do innych danych. Stan i fokus mogą więc przejść do niewłaściwego zadania, a dopiero ostatni element zostanie zamontowany od nowa. To błąd tożsamości elementów, nie tylko problem wydajnościowy.
Przyczyna 7: useMemo z niestabilną zależnością
Funkcja zadeklarowana w ciele komponentu dostaje nową referencję przy każdym renderze, więc useMemo przelicza wynik za każdym razem. Najprostsza naprawa to nie przekazywać funkcji jako zależności: utwórz formatator bezpośrednio w obliczeniu i zależ od danych, które naprawdę wpływają na wynik.
Diagnostyka: linter z eslint-plugin-react-hooks ostrzega przed brakującymi zależnościami, ale nie oceni, czy zależność jest stabilna ani czy memoizacja oszczędza zauważalny czas. Sprawdź to w Profilerze.
Naprawa bez memoizacji: kompozycja zamiast memo
Zanim owiniesz cokolwiek w React.memo, sprawdź, czy problemu nie rozwiązuje sama struktura komponentów. Dwie techniki kompozycji eliminują całe klasy ponownych renderowań bez jednej linijki memoizacji.
Technika 1: przenieś stan w dół
Jeśli stan żyje wyżej, niż jest używany, każda jego zmiana renderuje ponownie wszystko po drodze:
Technika 2: przekaż ciężkie dzieci przez children
Gdy stan musi zostać wysoko (np. motyw, układ), przekaż ciężką zawartość jako children. Element przekazany z zewnątrz zachowuje tę samą referencję między renderami komponentu opakowującego, więc React pomija jego ponowne renderowanie:
Kiedy sięgnąć po React.memo
Dopiero gdy kompozycja nie wystarcza, w sytuacjach, gdy komponent jest realnie ciężki (duża lista, skomplikowane drzewo), a jego rodzic renderuje się ponownie często z powodów, na które nie masz realnego wpływu. Wtedy React.memo plus stabilne propsy (Przyczyny 1-2) zatrzymają kaskadę. Pamiętaj jednak, że to kosztuje, bo memo porównuje propsy przy każdym renderze rodzica i dla lekkich komponentów bywa droższy niż sam render. Pamiętaj, że memo nie naprawi niestabilnych referencji, bo nie działa zamiast nich, ale z nimi w parze.
Proces debugowania ponownych renderowań krok po kroku
Mój typowy przebieg, kiedy zauważam problem z wydajnością:
-
Odtwórz problem. Czy ponowne renderowania pojawiają się przy montowaniu? Przy kliknięciu? Przy pisaniu w polu? Zidentyfikuj wyzwalacz.
-
Nagranie w Profilerze. Nagraj interakcję.
-
Znajdź najcięższy komponent. Na wykresie płomieniowym szerokość bloku odpowiada kosztowi renderu; zacznij od najszerszych bloków w commicie wywołanym badaną interakcją.
-
Sprawdź, dlaczego się renderuje. Zakładka „Why did this render?" — zmiana propsów? Stanu? Ponowne renderowanie rodzica?
-
Jeśli to props — sprawdź każdy prop. Który się zmienił? Dlaczego?
-
Idź w górę drzewa — dlaczego rodzic tworzy nowy prop? Funkcja tworzona w miejscu, obiekt tworzony w miejscu, zmiana Contextu?
-
Napraw na poziomie źródła. Zmiana na stabilną referencję, memoizacja, podział Contextu.
-
Zmierz ponownie. Ta sama interakcja, Profiler, sprawdzenie, czy ponowne renderowania zniknęły lub zmalały.
Dla dużych problemów robię to iteracyjnie, czyli naprawiam najcięższy komponent, mierzę, a potem idę do następnego i tak do końca.
Co mierzyć po optymalizacji renderowania?
Sama informacja „mniej renderów" jest za słaba. Po refaktorze zapisuję krótki wynik, który da się porównać za miesiąc:
| Metryka | Po co ją mierzyć | Dobry sygnał |
|---|---|---|
| Czas commitu w Profilerze | Pokazuje koszt faktycznego commitu Reacta | Spadek dla konkretnej interakcji |
| Liczba renderów komponentu | Pomaga znaleźć kaskady i niestabilne propsy | Spadek po stabilizacji referencji |
| INP / responsywność pola | Pokazuje, czy użytkownik realnie czuje zmianę | Krótsze opóźnienie po kliknięciu lub wpisaniu znaku |
| Spowolnienie CPU 4x | Symuluje słabszy laptop lub telefon | Interakcja nadal jest płynna |
Jeśli Twoim głównym problemem jest responsywność interakcji, zejdź poziom niżej — w artykule o 5 wzorcach, które niszczą Interaction to Next Paint w React rozbieram INP od strony konkretnych wzorców kodu.
React 19: useTransition i useDeferredValue
Te mechanizmy są z nami od Reacta 18, ale w React 19 nadal pozostają podstawowym sposobem oddzielania pilnych interakcji od cięższych aktualizacji interfejsu.
useTransition — „ta zmiana nie jest pilna, React może ją przerwać":
React może przerwać renderowanie ResultList, jeśli użytkownik nadal pisze. Wtedy pole pozostaje responsywne, nawet gdy wyniki są ciężkie do policzenia. Warto pamiętać, że startTransition wpływa wyłącznie na priorytet renderu będącego skutkiem zmiany stanu. Sam kod wewnątrz przekazanego callbacku nadal wykonuje się synchronicznie i nie da się go przerwać. Gdybyś policzył wyniki synchronicznie w obsłudze zdarzenia (setResults(searchExpensive(query))), ciężka praca i tak zablokowałaby wątek, ponieważ przerwać da się tylko to, co dzieje się podczas renderowania.
useDeferredValue, czyli renderuj ze starszą wartością, ale przygotuj nową:
Te mechanizmy nie naprawiają niepotrzebnych renderów, ale pomagają zarządzać priorytetami, gdy render jest realnie ciężki.
