W niniejszej serii przedstawię kompleksowy zestaw artykułów, z których każdy skupi się na istotnym aspekcie tego zagadnienia.

Poniższa treść poświęcona jest przedstawieniu Reactowego ContextAPI, jego najważniejszych cech oraz obrazuje główne techniki optymalizacyjne.

Optymalizacja ContextAPI

Przejdźmy już do głównej części artykułu. W naszym omówieniu weźmiemy za przykład kontekst, który używa jakiegoś prywatnego stanu, na przykład `useReducer` albo `useState`. Właścicielem stanu jest Context.Provider. Zaś sam kontekst używany jest jako transporter. Dzięki temu podejściu zyskujemy:


  • redukcję props drilling

  • zmniejszenie couplingu między komponentami

Narażamy się jednak na:

  • problemy z setupowaniem testów (tj. między innymi tworzenie mocków)

  • performance (wynikający z nadprogramowego przerenderowania się komponentów)

Skupmy się na drugim z tych problemów by znaleźć najlepsze rozwiązanie optymalizacyjne.


Najczęstsze błędy

Zacznijmy od tego, jak nie należy używać Contextu. Zasadniczo, błędem jest stworzenie takiego Context Providera, który jednocześnie ma swój stan oraz renderuje swoją zawartość bezpośrednio wewnątrz siebie. Komponenty będące zawartością kontekstu, będą przerenderowywane, każdorazowo, gdy zmieni się stan kontekstu. Możemy mieć pewność, że wśród komponentów-dzieci znajdą się takie, które nie muszą się przerenderowywać przy każdej zmianie.

Reakcją na zmianę przedstawionego wyżej stanu jest rerender komponentu. I renderując siebie, `WrongProvider` renderuje od razu swoją zawartość. Wykorzystując wiedzę o mechanizmie renderu w React (mianowicie to, że jeśli komponent przyjmuje children jako gotowca, to wtedy ten komponent nie renderuje childrenów), można oddelegować decyzję o renderze komponentów-dzieci do komponentu-rodzica. Tworzymy w ten sposób odrobinę lepszy `BetterProvider`.

W `BetterProvider`, kiedy zmieni się stan kontekstu, przerenderują się wszyscy subskrybenci. A jeśli komponent znajduje się w children, ale nie subskrybuje kontekstu, nie ma bezpośredniego powodu do rerendera.

Zasadniczo, kontekst powoduje renderowanie wszystkich subskrybentów, kiedy zmieni się wartość kontekstu. Niestety, w praktyce oznacza to, że konsumenci renderują się znacznie częściej, niż potrzeba. Dzieje się tak dlatego, że context może przechowywać wiele danych i funkcji, zaś komponenty z reguły będą zainteresowane tylko niewielkim ich fragmentem. W takim przypadku jeśli zmienią się jakiekolwiek dane (nawet takie, które nie są wykorzystywane przez komponent) subskrybent i tak się przerenderuje.

Należy tu wspomnieć o dwóch mitach mówiących że problem ten można obejść. Na przykład, że destrukturyzacja wartości kontekstu blokuje rendery.
Sprostujmy ten mit: Wiemy już, że subskrybenci renderują się każdorazowo, gdy zmieni się wartość. I jeśli wartość dostaje nową referencję, to destructuring konsumentów nie ma znaczenia.

Kolejnym takim mitem jest: memo albo pure component "odbija" rendery, jeśli komponent dostaje te same propsy.

I znów sprostujmy: Memo może odbić rendery, jeśli te są spowodowane renderowaniem bezpośredniego rodzica. Ale na kontekst memo wpływu nie ma.

Na szczęście z tym problemem także możemy sobie poradzić stosując różne techniki.


Memoizacja wartość kontekstu

Niezależnie czy używamy `useState` czy `useReducer`, to i tak najczęściej mamy zarówno gettery jak i settery. Gdyby przekazywać je bezpośrednio jako wartość kontekstu, to każdorazowo tworzona byłaby nowa referencja. Każdorazowy render providera resetowałby wartość kontekstu, a to w konsekwencji przerenderuje wszystkich subskrybentów. Optymalizacja jest na szczęście małoinwazyjna. Wystarczy opakować wartość kontekstu w useMemo. Dzięki temu, jeśli nie zmieni się żadna z zależności, nowa referencja nie będzie niepotrzebnie tworzona.

Separacja setterów i getterów

Jest to metoda bardziej inwazyjna, gdyż rozbijamy duży kontekst, tworząc osobny provider na gettery i osobny provider na settery. Istotne w tym podejściu jest to, że stan pozostaje taki, jaki był. Przykładowo, jeśli używamy `useReducer`, to osobno transportujemy state, a osobno dispatch. Można zauważyć tu analogię do stylu architektonicznego CQS (czytaj: command query separation), w którym oddzielamy operacje czytające dane od operacji modyfikujących je. Tutaj w ramach kontekstów: read oraz write dostają osobne transporty. Bez tej optymalizacji zmiana wartości kontekstu powoduje re-render wszystkich subskrybentów. Bez względu na to, czy czytają stan, czy dispatchują akcje. Natomiast jak zaaplikujemy osobne providery, to zyskujemy to, że te komponenty, które tylko dispatchują, nie będą rerenderowane. Bo dispatch, który jest do nich transportowany nie zmienia się, gdyż reactowy `useReducer` gwarantuje jego stabilność. Taką samą stabilność osiągnęlibyśmy gdybyśmy dostarczyli własne callbacki (na przykład opakowując `useState` poprzez `useCallback`). Rozwiązanie działa, dopóki to, co jest transportowane się nie zmienia. Ważne jest tu, że celowo nie tworzymy wspólnego hooka, który połączy oba providary. Bo taki wspólny hook zniweczyłby korzyści, ponownie subskrybując oba transporty.

Podsumowanie


Optymalizacja kodu jest kluczowym aspektem w procesie tworzenia aplikacji, jednak należy podejść do niej z rozwagą. Choć może przyspieszyć działanie aplikacji, zbyt agresywne optymalizacje mogą skomplikować strukturę kodu i utrudnić jego zrozumienie dla innych programistów. Przed przystąpieniem do optymalizacji warto przeprowadzić analizę wydajnościową za pomocą narzędzi profilujących, aby zidentyfikować rzeczywiste źródła problemów. W niektórych przypadkach korzystne może być podzielenie dużych kontekstów na mniejsze, co może zmniejszyć obciążenie renderowania, szczególnie w przypadku aplikacji z wieloma subskrybentami. Po wprowadzeniu optymalizacji istotne jest regularne monitorowanie i sprawdzanie efektów za pomocą profilerów, aby upewnić się, czy poprawiły one wydajność aplikacji. W ten sposób można uniknąć sytuacji, w której zmiany wprowadzone w celu optymalizacji pogarszają działanie systemu.

Premature optimization is the root of all evil