RecoilJS - eksperymentalny state manager od Facebooka

Zanim powiemy sobie nieco więcej o Recoilu to cofnijmy się kilka kroków do... Reduxa. Na pewno znacie albo chociaż o nim słyszeliście. Jest to obecnie najpopularniejszy state manager używany w ekosystemie Reacta. Można go uwielbiać albo nienawidzić, ale jego popularność świadczy o tym, że robi swoją robotę. Już kilkukrotnie robił się szum w społeczności, że nadciąga “pogromca” Reduxa, kiedy to powstawały kolejne biblioteki do zarządzania stanem aplikacji albo team Reacta wypuścił na świat coś nowego co w jakimś stopniu wiązało się z zarządzaniem stanem (mam tu na myśli chociażby Context i Hooki ;)). Czy wreszcie nadciąga rycerz na białym koniu, który wybawi nas od Reduxa? W mojej opinii - nic z tych rzeczy. Po prostu powstaje kolejne narzędzie, które może (ale nie musi) być wygodniejsze do zastosowania przy zarządzaniu stanem aplikacji w danym przypadku.

Re… co(il)?

Recoil jest state managerem, który 14. maja 2020 roku został udostępniony w fazie eksperymentalnej przez zespół Facebooka na GitHubie. Biblioteka ta na pewno jest warta uwagi. Pomimo tego, że nie należy do React Core to została idealnie dopasowana do obecnego stanu Reacta i pozwala na bardzo łatwe przeskoczenie z zarządzania stanem lokalnym komponentów z użyciem Hooków do stanu globalnego opartego o podobne API.

Nieco teorii zanim przejdziemy do kodu

Recoil opiera się o trzy podstawowe koncepty - atomy, selektory i kontener:

  • Kontener (RecoilRoot) – aby korzystać z zarządzania stanem przy pomocy Recoila musimy umieścić w drzewie komponentów RecoilRoot. Co należy pamiętać, to żeby znajdował się powyżej komponentów, którymi zarządza. Inaczej się z nim nie połączymy. Dobrym miejscem, w którym można go umieścić jest plik wejściowy do naszej aplikacji (najczęściej App) - wystarczy opakować wszystko w i korzystać.
  • Atomy – reprezentują fragment stanu. Pozwalają zarówno na jego odczyt, jak i modyfikację. Powyższe operacje można robić z poziomu dowolnego komponentu znajdującego się wewnątrz RecoilRoota. Komponenty odczytujące stan z danego atomu zostają z nim powiązane (poprzez subskrypcję) i przy każdej zmianie stanu atomu zostaną ponownie wyrenderowane z nowymi wartościami.
  • Selektory – są funkcjami czystymi przyjmującymi atomy lub inne selektory jako parametr wejściowy, na podstawie którego wyliczają swój stan. Będziemy ich głównie używać do odczytu, jednak dostarczają też API pozwalające na zmiany stanu. Selektory są uaktualniane z każdą zmianą atomu lub selektora, od którego zależą.

    Połączenie atomów i selektorów ma tę zaletę, że przy ich użyciu w globalnym stanie (atomach) trzymamy minimalne dane niezbędne do zarządzania aplikacją. Wszystkie ich pochodne natomiast, które wykorzystywane są jedynie do prezentacji (odczytu), zostaną dynamicznie wyliczone przy pomocy selektorów. W ten sposób unikamy “rozpychania” stanu globalnego pochodnymi stworzonymi na potrzeby poszczególnych widoków i zachowamy jego spójność przeliczając selektory zwracające wartości tylko do odczytu przy każdej zmianie powiązanych atomów.

A teraz do konkretów...

Poznając Recoila najpierw napisałem prosty przykład Countera, zliczającego stan wynikający z wciskania przycisków “-”, “+” oraz “RESET”. Niestety jak tylko głębiej wskoczyłem w dokumentację to okazało się, że ten przykład mnie ogranicza. Zabrakło mi pomysłu jak mogę sensownie w nim skorzystać z selektorów. W wyniku niezadowolenia samoograniczeniem napisałem jeszcze jeden prosty przykład - tym razem skierowany typowo pod selektory. Był to kalkulator wyliczający sumę z dwóch cyfr. Poniżej znajdziecie kod źródłowy obu przykładów, wraz z omówieniem.

Dla ukazania prostoty przeskoczenia z Hooków do Recoila przykład Countera będzie najpierw napisany na Hookach, po czym zmigrujemy zarządzanie jego stanem do Recoila. A teraz… do kodu!

Zacznijmy od zbudowania Countera opartego o useState. Rozwiązanie będzie oparte o styled-components. Nie będę się teraz na nich skupiał - w niedalekiej przyszłości na pewno napiszę o tym kilka słów.

recoil-counter

// Counter.jsx
export const Counter = () => {
const [counter, setCounter] = useState(counterState);

return (
  <Styled.Form>
    <Styled.Counter>{counter}</Styled.Counter>
    <Styled.Buttons>
      <Styled.DecrementButton onClick={() => setCounter(counter - 1)}>
        -
      </Styled.DecrementButton>
      <Styled.ResetButton onClick={() => setCounter(0)}>
        RESET
      </Styled.ResetButton>
      <Styled.IncrementButton onClick={() => setCounter(counter + 1)}>
        +
      </Styled.IncrementButton>
    </Styled.Buttons>
  </Styled.Form>
);
};

Jak widzicie - póki co bez fajerwerków. Zwróćcie uwagę, że obecnie stan mamy oparty o useState. Spróbujmy zatem odtworzyć to samo z użyciem Recoila. Zgodnie z dokumentacją powinniśmy najpierw opakować nasze komponenty w RecoilRoot, ja owinę nim całą aplikację:

// index.jsx
<RecoilRoot>
  <App />
</RecoilRoot>

To nie było zbyt trudne. Kolejnym krokiem jaki zrobimy będzie utworzenie atomu, który zostanie opisany unikalnym kluczem i będzie zawierał inicjalny stan jaki reprezentuje.

// state.js
export const counterState = atom({
  key: "counterState",
  default: 0,
});

Następnie możemy z niego skorzystać używając hooka useRecoilState dostarczanego przez Recoil. Zapewnia on dokładnie takie samo API jak useState, w związku z czym w naszym komponencie jedyne co się zmieni to jedna linijka opisująca nasz lokalny stan. Nowe powiązanie ze stanem ma więc następującą formę:

// zastąp linijkę z useState w Counter.jsx
const [counter, setCounter] = useRecoilState(counterState);

I… to już! Osiągnęliśmy dokładnie takie samo działanie co w przypadku useState, z tą różnicą, że teraz mamy globalny stan. Jeżeli byśmy chcieli, możemy utworzyć inny komponent, który będzie z niego korzystał (np. może być oparty o selektor żeby móc tylko odczytywać wartości) i automatycznie stanie się on synchroniczny do stanu zawartego w atomie.

Przejdźmy teraz do SumCalculatora. Napisałem już małe co nieco o selektorach, ale dużo łatwiej będzie zrozumieć ich działanie, kiedy poprzemy je przykładem użycia. Stwórzmy więc prosty kalkulator dodający dwie cyfry. Przejdziemy podobną ścieżkę jak w przypadku komponentu Countera - zacznijmy od stanu lokalnego z użyciem Hooków, a następnie wskoczmy w to co oferuje nam poznawane API.

recoil-sum-calculator

// SumCalculator.jsx
export const SumCalculator = () => {
  const [inputsState, setInputsState] = useState({
    firstNumber: 0,
    secondNumber: 0
  });

  const handleInputChange = (e) => {
    const { name, value } = e.target;

    setInputsState({
      ...inputsState,
      [name]: value,
    });
  };
  
  const calculateSum = () => {
    const { firstNumber, secondNumber } = get(sumInputsState);

    const result = Number(firstNumber) + Number(secondNumber);

    if (typeof result !== "number" || isNaN(result)) return 0;
    return result;
  }

  return (
    <Styled.Form>
      <Styled.Sum>{calculateSum()}</Styled.Sum>
      <Styled.Inputs>
        <Styled.NumberInput
          name={"firstNumber"}
          value={inputsState.firstNumber}
          onChange={handleInputChange}
        />
        <Styled.NumberInput
          name={"secondNumber"}
          value={inputsState.secondNumber}
          onChange={handleInputChange}
        />
      </Styled.Inputs>
    </Styled.Form>
  );
};

Jak widzicie, zdefiniowaliśmy stan dla obu inputów, metodę pozwalającą na aktualizację tego stanu oraz metodę wyliczającą sumę, którą następnie wyświetlamy przy renderowaniu. Jak to będzie wyglądało z użyciem Recoila? Podobnie jak w poprzednim przykładzie - najpierw zdefiniujmy atom zawierający nasz minimalny stan.

// state.js
export const sumInputsState = atom({
  key: "sumInputsState",
  default: {
    firstNumber: 0,
    secondNumber: 0,
  },
});

Stan obsługujący nasze inputy wygląda dokładnie tak samo jak nasz useState, z tą różnicą że posiada dodatkowy klucz i jest wyciągnięty poza komponent (co swoją drogą poprawia czytelność). A jak będzie wyglądało wyliczenie sumy? Właśnie w tym miejscu wchodzą selektory. Jak wcześniej wspomniałem są to czyste funkcje, które zwracają nam przetworzone wartości będące tylko do odczytu. Zdefiniujmy więc selektor dla sumy. Jego logika zostanie przeniesiona wprost z komponentu sumCalculatora.

// state.js
export const sumState = selector({
  key: "sumState",
  get: ({ get }) => {
    const { firstNumber, secondNumber } = get(sumInputsState);

    const result = Number(firstNumber) + Number(secondNumber);

    if (typeof result !== "number" || isNaN(result)) return 0;
    return result;
  },
});

Jak widzicie jest do prawie kopia stanu, który mieliśmy używając podstawowych Hooków reactowych. Jedynymi różnicami jest - podobnie jak w przypadku atomów - opisanie kawałka stanu unikalnym kluczem oraz pole get pozwalające nam później korzystać z selektora. Miejscem, które może zastanawiać jest destrukturyzacja geta w parametrze oraz późniejsze jego wykorzystanie z argumentem sumInputsState. Już spieszę z wytłumaczeniem. W ten sposób Recoil pozwala nam pobierać wartości z innych atomów lub selektorów i dalej je przetwarzać na nasze potrzeby. Możemy więc przepuścić jedną wartość przez kilka selektorów i stopniowo ją modyfikować zależnie od potrzeb jakie mamy.Jeszcze słowo o tym jak teraz wygląda stan naszego komponentu. Zamieniliśmy useState na useRecoilState (metoda pozwalająca zarówno na odczyt, jak i zapis) a wyliczoną w selektorze sumę pobieramy za pomocą funkcji useRecoilValue (stanowiącą API tylko do odczytu).

// zastąp state i wyliczenie sumy w SumCalculator.jsx
const [inputsState, setInputsState] = useRecoilState(sumInputsState);
const sum = useRecoilValue(sumState);

Proste prawda? Według mnie nawet bardzo proste, a do tego zapewnia bardzo niski stopień powiązania kodu co znacznie ułatwia późniejsze jego utrzymanie.

Z dzisiejszego tematu to by było na tyle. Jeżeli chcecie się pobawić przykładem, zapraszam na GitHuba, tam możecie go pobrać i do woli eksperymentować.

Więcej do poczytania / obejrzenia o Recoilu tutaj:

Do góry