W multiwersum Reacta

Część pierwsza. Obiekt obiektowi nierówny

Obiekt obiektowi nierówny

Piękne początki Reacta

Lubię prosty, funkcyjny model Reacta. Byłem jego fanem od samych początków. Drzewo danych trzymamy osobno, widoki trzymamy osobno. Same widoki to kompozycja czystych funkcji: przyjmuję dane, wypluwam HTML. Co jeśli aplikacja działa wolno? Pewnie któryś z komponentów jest zbyt duży, trzymasz stan za wysoko w drzewie komponentów, lub nie używasz odpowiednio specyficznych selektorów i obserwujesz zbyt dużo danych naraz. Miałem proste wymagania – funkcyjna, w miarę szybka, biblioteka do zarządzania widokami – i React je spełniał.

Architektura może być prosta

Zawsze uważałem, że lokalnie, w komponentach można trzymać tylko stan UI (wybrany tab, otwarty/zamknięty akordeon, obrazek wybrany z galerii, etc…), lub stan formularza. Zarządzanie stanem domenowym przez useState i useEffect powoduje same problemy: trzeba implementować własny error handling, trudno dzielić stan z innymi komponentami, nie występuje cache, useEffect łatwo zepsuć tak, żeby zabił całą aplikację. Dlatego dane domenowe trzymamy w zewnętrznym storze. Mamy świetne narzędzia, żeby to robić. React Query i React Router pozwalają, w elegancki sposób, zarządzać stanem większości typowych aplikacji. Jeśli mamy dużo lokalnego stanu do wyboru jest Redux odświeżony o Redux Toolkit, bardzo elastyczny Jotai, który ma swój własny sposób na budowanie wydajnych aplikacji, a dla reaktywnych szaleńców zawsze pozostaje potężny RxJS. State-management jest dla mnie problemem, w większości, rozwiązanym. Mamy do tego dobre narzędzia – trzeba się ich nauczyć i używać.

Co robi React Compiler

React Compiler optymalizuje zarządzanie stanem, tak, żeby programista mógł mniej zastanawiać się nad wydajnością swojego kodu. Compiler identyfikuje komponenty, oraz transformacje danych wymagające optymalizacji i stosuje na nich automatyczne memoizacje. Dzięki temu unikamy niepotrzebnych obliczeń i renderów. Memoizacje sprawią, że nasze komponenty będą renderować się tylko wtedy kiedy propsy, lub stan, naprawdę się zmienią. Koncepcyjnie jest to coś jak automatyczne stosowanie useMemo, memo i useCallback, choć w praktyce React generuje statyczny kod do memoizacji, zamiast prostego wywoływania tych fukncji. Od kilku tygodni zastanawiam się czy to dobry pomysł.

Co chciałbym, żeby React Compiler robił

Wolałbym, żeby React Compiler znajdywał takie „ryzykowne” miejsca w aplikacji i informował o nich programistę. Sugerował, że należy wynieść zarządzanie stanem poza widok, zadbać o selektory umieszczone w odpowiednim miejscu drzewa komponentów, czy podzielić komponenty na mniejsze. W obecnej wersji widzę React Compiler jako sposób na ukrycie kiepskiej architektury. Wolałbym, żeby kierował developera w stronę poprawy architektury, a nie ukrywania błędów.

Kolejne warstwy abstrakcji

Po drugie: React daje duży narzut abstrakcji już na wejściu. Komunikacja z przeglądarką jest ukryta za Virtual DOM i systemem syntetycznych eventów. React Compiler to kolejna abstrakcja, która oddala programistę od kodu, który pisze. Compiler sugeruje: pisz useState, pisz useEffect, twórz event handlery w środku komponentów, pisz transformacje danych w komponentach a, ja to wezmę i zrobię tak, żeby było szybko. Może będzie szybciej, ale architektura dalej będzie kiepska. W którymś momencie kolejna memoizacja nie wystarczy, jeśli transformacje danych i komponenty staną się zbyt skomplikowane.

Czy React Compiler jest potrzebny?

Po trzecie: React, by design, lubi immutable data i opiera swoje optymalizacje, nie na nasłuchiwaniu na eventy informujące o zmianie danych, ale na porównywaniu nowych danych ze starymi. Reactowy useState emituje nową wartość kiedy ustali, że różni się ona od poprzedniej. Świetna optymalizacja, tylko, że sprawdzanie równości dwóch zmiennych odbywa się Object.is. Stringi i liczby porównywane są przez wartość, ale tablice, mapy, sety, zasadniczo wszystkie obiekty, porównywane są przez referencję. To znaczy, że 2 === 2 i "Alan Turing" === "Alan Turing", ale [] !== [], new Set([1, 2]) !== new Set([1, 2]), etc… Jeżeli zamienilibyśmy reference equality na value equality moglibyśmy, automatycznie, uniknąć wielu nadmiernych renderów. Dodatkowo, jeśli value equality checki byłyby, w miarę, tanie – dostalibyśmy darmową optymalizację, bez ręcznej memoizacji, ani angażowania Compilera.

ClojureScript ❤️ React

W takim pięknym świecie żyłem kiedy pracowałem w WorksHubie. Na froncie używaliśmy Clojure’owych bibliotek Reagent i re-frame. Reagent to wrapper na React. re-frame to coś jak Redux, tylko, że z immutable, persistent data i value equality zamiast reference equality. Immutable i persistent znaczy, że 1) danych nie można modyfikować, można tylko tworzyć nowe wersje i 2) te nowe wersje tworzone są przez trzymanie referencji do starej wersji i dodawanie nowych elementów. Jeśli dane są immutable, referencje są stałe, zawsze wskazują te same dane. Z tego powodu stworzenie tablicy z nowym elementem oznacza (w uproszczeniu) trzymanie referencji do oryginalnej tablicy, dodanie referencji do nowego elementu i gotowe. Nowe dane to stare dane plus mała zmiana.

Jaki jest minus takich struktur? Value equality check jest tani dopóki struktury danych są, względnie, niewielkie, albo bardzo łatwo jest stwierdzić, że dane się różnią (np. dwie tablice mają inny rozmiar). Jeśli mamy dwa obiekty, które są prawie takie same, ale gdzieś daleko, na końcu, mają elementy, które się różnią, pełen equality check może zająć więcej czasu. Dodatkowo, jeśli strukturę w pamięci porównujemy z nowymi danymi, które przyszły, np. po HTTP, musimy zrobić pełen deep equality check. Dlatego nie wszyscy w ClojureScript community zalecają używanie value equality. roman01la, twórca biblioteki uix, zaleca używanie reference checków jako domyślnego sposobu na change detection, ponieważ value check bywa kosztowny.

Z mojego doświadczenia minusy nie przesłaniają plusów. Immutable, persistent data structures są świetnym, domyślnym wyborem do pracy z Reactem. Zmienne porównujemy zawsze po wartości. Zero zbędnych renderów, chyba, że zepsujesz logikę. Równość [] === [] jest zawsze prawdziwa. Jeśli value checki są za drogie, pewnie masz jeden z tych problemów: za duże komponenty, za duże struktury danych, lub selektory ustawione zbyt wysoko w drzewie komponentów i nasłuchujące na zbyt duży fragment stanu. Problemy z wydajnością występują, ale ich rozwiązania są znane.

JavaScript sto lat za Clojure…

Nawet podoba Ci się to podejście jest pewien problem. Ciągle mówimy o Clojure. W JavaScripcie niemutowalne struktury danych nie występują natywnie. Mamy kilka bibliotek, które implementują ten model, żadna nie jest idealna.

Jest biblioteka immer, która wymusza niemutowalność danych i zapewnia structural sharing. Wszystko to działa na bazie zwykłych, JSowych struktur danych, opakowanych w funkcję produce. Minusem jest to, że ciągle możemy wywołać rerender, nawet jeśli dane wyglądają tak samo. immer nie robi value checków na danych, które przychodzą z zewnątrz.
Jeśli mamy w immerze obiekt data = [{ prop: "value" }] i dokonamy transformacji data[0] = { prop: "value" } immer nie będzie umiał rozpoznać, że nic się nie zmieniło. Z plusów: immer nie wymaga uczenia się nowych API do modyfikacji danych.
Pozwala zapomnieć o spread operatorze: deep.object.assign = "works";. Piszemy kod jak w normalnym JSie a immer dba o to, żeby z punktu widzenia konsumenta, dane były immutable. Korzystam z immera w obecnym projekcie, za pośrednictwem Redux Toolkit i możliwość używania normalnego, JSowego API do czytania i zapisywania danych jest bardzo wygodna.

Mamy też Immutable.js, która daje pełną implementację immutable, persistent data structures. Tutaj minusem może być API. Trzeba nauczyć się używać getIn, setIn i podobnych funkcji, które nie działają na natywnych, JSowych strukturach danych. Wiele osób ma problem z odpowiednim użyciem Immutable.js i, zamiast przyspieszenia aplikacji, dostają mocne spowolnienie. Głównym winnym są częste transformacje fromJS i toJS, oraz tworzenie dużych struktur danych na których, jak pisałem wcześniej, czasem trzeba wykonywać deep equality check o złożoności O(N). Z plusów, Immutable.js stara się używać sprytnych optymalizacji przy transformacjach danych. Jeśli wykryje, że zmiany, które wprowadzasz do obiektu, w rzeczywistości, nie zmieniają jego wartości – zwróci oryginalny obiekt, o tej samej referencji. Dodatkowo, używa reference checków jako pierwszego sposobu na sprawdzenie równości dwóch obiektów i przechodzi do value checków, jeśli reference check zwróci false. Jeśli coś da się porównać szybko, bez wchodzenia w głębokie porównanie wartości – Immutable postara się to zrobić.

Nowa Nadzieja – Records & Tuples

Kilka lat temu pojawił się nowy gracz na scenie. Wszedł raczej skromnie, ale liczę, że zwiastuje ciekawą przyszłość. Mówię o „ECMAScript proposal for the Record and Tuple value types”. Jest to propozycja dodania niemutowalnych obiektów i tablic do JavaScriptu. Mają zapewnić tanie value equality bez dodatkowych bibliotek. Idealne wyjście do pracy z Reactem. W proposalu najbardziej interesująca jest dla mnie sekcja What are the performance expectations of these data structures?, która opisuje możliwe optymalizacje dla implementacji szybkich deep equality checków. Dokument doszedł już do Stage 2, co oznacza, że będzie dalej rozwijany i kiedyś zostanie wprowadzony do standardu ECMAScript. Jakie są minusy? Polyfill istnieje i można go już używać, ale nie zapewnia dobrej wydajności. Nie zmienia to faktu, że kod jest ciekawy i polecam przeczytać w wolnej chwili https://github.com/bloomberg/record-tuple-polyfill. Implementacja Records & Tuples, z polyfilla, używa techniki zwanej „interning”. Każda wartość, którą trzymamy w tych strukturach, jest zapisywana w pamięci tylko raz, w globalnym grafie wartości. Gdy tworzymy nowe Tuples & Records, graf jest przeszukiwany, żeby sprawdzić czy dane, które w nich trzymamy nie były już wcześniej zapisane. Jeśli tak – dostajemy referencję do starych danych. Ciekawy pomysł. Nie miałem okazji sprawdzić tych struktur na większej aplikacji. Ich wydajność na 99% będzie kiepska, skoro nawet autorzy polyfilla się do tego przyznają. Pozostaje czekać na zoptymalizowane implementacje ze strony przeglądarek, choć to może potrwać kilka lat.

Podsumowanie

React lubi immutable data. Wręcz nie może bez nich dobrze działać. Implementacje mogą być różne. Może to być spread operator, może być Immer, czy Immutable.js. Może być ClojureScript i jego natywne struktury danych. React Query, o którym pisałem na początku, używa „structural sharingu” żeby optymalizować ilość renderów w aplikacji. Jeśli korzystasz z tej biblioteki to używasz technik pochodzących z immutable data structures i nawet nie musisz o tym wiedzieć.
I ja to szanuję. Immutable, persistent data structures po prostu idealnie pasują do Reacta. Czekam na rozwój Records & Tuples. Czekam na świat w którym większość memoizacji nie jest potrzebna, bo dzięki stosowaniu value equality rerenderujemy komponenenty tylko wtedy, kiedy dane naprawdę się zmieniły.
Czy wtedy React Compiler przestanie wtedy być potrzebny? Zależy w którą stronę będzie się rozwijał. Obecnie patrzę na niego z dystansem. Liczę, że Reactowcy zaadaptują Records & Tuples i przejdą do rzeczywistości, w której referencje są stałe, dane zawsze porównujemy po ich dokładnej wartości, a ilość nadmiarowych renderów stale wynosi 0.

Żeby dowiedzieć się więcej o Compilerze polecam artykuły:

Żeby poznać świat z perspektywy immutability i ClojureScripta polecam:

Napisz do mnie
a.fricze@pm.me