Nie tylko Redux – poznaj 4 inne sposoby na zarządzanie stanem w React

Redux, jak i dedykowaną dla Reacta paczkę React Redux można uznać za najpopularniejsze w tym momencie rozwiązanie do kompleksowego zarządzania stanem w React.
Redux potrafi być pobrany ponad 900 tysięcy razy dziennie (za npm-stat), natomiast React Redux ponad 650 tysięcy razy. Nie jest jednak rozwiązaniem idealnym i ma pewne wady:

  1. Duża ilość powtarzalnego kodu, tzw boilerplate
  2. Stan jest globalny – można go modyfikować z wielu miejsc (z dowolnego miejsca można nadać akcję)
  3. Nieodpowiednio użyty może spowodować problemy wydajnościowe

Czy istnieją alternatywy? Tak. Nie tak popularne jak Redux, ale rozwiązujące niektóre problemy inaczej.


Alternatywy dla Reduxa

  1. React Context
  2. MobX
  3. MobX state tree
  4. Reusable

React Context

React Context to natywne API Reacta służące do przechowywania wartości potrzebnych w wielu miejscach. Powstał jako odpowiedź na tzw prop drilling, czyli sytuację przekazywania propsów przez wiele poziomów drzewa komponentów aż dotrze do tego, który danego propsa potrzebuje.

Tworzenie i używanie kontekstu

Kontekst stworzymy używając funkcji createContext() która jako parametr przyjmuje wartości początkowe:

const ThemeContext = createContext({
  theme: 'No theme set',
  setTheme: (value) => {console.log(value)}
});

Aby pobrać wartość użyjemy hooka useContext, który jako parametr dostanie utworzony przez nas kontekst i od tej pory będzie pilnował, by za każdym razem gdy wartości kontekstu zostaną zmienione były one dostarczane do komponentu:

const ThemeInfo = () => {
  const { theme  } = useContext(
    ThemeContext
  );

  return (
    <div>
      <p>
        The theme is {theme}
      </p>
    </div>
  );
};

Co jednak trzeba zrobić, by dać możliwość nadpisania obecnych wartości? Należy użyć Providera, który będzie dostawał nowe wartości. Same wartości natomiast mogą znajdować się w stanie komponentu w któym Provider jest użyty:

const App = () => {
  const [theme, setTheme] = useState("light");

  return (
    <ThemeContext.Provider
      value={{ theme, setTheme }}
    >
      <ThemeInfo />
    </ThemeContext.Provider>
  );
};

W takiej sytuacji do każdego komponentu w aplikacji zostanie przekazana funkcja setTheme. Wywołanie jej zmieni stan w komponencie App, wtedy Provider dostarczy do kontekstu nowe wartości a komponenty które używają hooka useContext te wartości dostaną.

React Context – pełny przykład

const ThemeContext = createContext({
theme: 'No theme set',
setTheme: (value) => {console.log(value)}
});

const ThemeInfo = () => {
const { theme } = useContext(
ThemeContext
);

return (
<div>
<p>
The theme is {theme}
</p>
<Buttons/>
</div>
);
};

const Buttons = () => {
const { setTheme } = useContext(
ThemeContext
);
return (
<>
<button onClick={() => setTheme('dark')}>Dark</button>
<button onClick={() => setTheme('light')}>Light</button>
</>
)
};

const App = () => {
const [theme, setTheme] = useState("light");

return (
<ThemeContext.Provider
value={{ theme, setTheme }}
>
<ThemeInfo />
</ThemeContext.Provider>
);
};

Zalety stosowania React Context

  1. Jest natywnym API Reacta
  2. Jest dość prosty, nie ma za dużo bolerplaite
  3. Nadaje się do używania w sytuacjach, gdy zmiany są rzadkie

Wady stosowania React Context

  1. Źle wypada w sytuacji, gdy zmiany są częste, ponieważ:
  2. Każda zmiana przerenderowywuje dzieci – potencjalne problemy z wydajnością
  3. Optymalizacja ręczna byłaby czasochłonna, biblioteki często mają zaimplementowane optymalizacje rerenderowania

MobX

MobX jest biblioteką zarządzania stanem bazującą na programowaniu reaktywnym. Jeżeli mamy jakąś istniejącą strukturę danych np klasę, tablicę czy zwykły obiekt JS, za pomocą MobX możemy dodać możliwość obserwowania i reagowania na zmiany jakie w niej zachodzą.

Przygotowanie aplikacji do użycia MobX

Po zainstalowaniu biblioteki i paczki dla Reacta poleceniami:

 npm install mobx mobx-react 

Możemy (nie musimy – biblioteka udostępnia API do pracy bez konieczności aktywowania dekoratorów) również włączyć obsługę dekoratorów których używa MobX w naszej aplikacji utworzonej za pomocą create-react-app. Trzeba zainstalować skrypty customize-cra które pozwolą nadpisać konfigurację Webpacka bez konieczności robienia eject, dzięki czemu nie będziemy musieli sami zarządzać konfiguracją:

 npm install customize-cra --save-dev 

Następnie trzeba zainstalować plugin Babela do importu modułów:

 npm install babel-plugin-import 

A potem w katalogu głównym aplikacji dodać plik config-overrides.js z zawartością:

const {
 override,
 disableEsLint,
 addDecoratorsLegacy,
 fixBabelImports,
} = require("customize-cra");module.exports = override(
 disableEsLint(),
 addDecoratorsLegacy(),
 fixBabelImports("react-app-rewire-mobx", {
 libraryDirectory: "",
 camel2DashComponentName: false
 }),
);

Zainstalujmy skrypty które będą uruchamiać aplikację:

npm install react-app-rewired --save-dev

Aby poprawne skrypty zostały odpalone, należy w package.json zamienić:

"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},

Na:

"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test — env=jsdom",
  "eject": "react-app-rewired eject"
}

Użycie MobX w praktyce

Stwórzmy klasę, która będzie przechowywać stan:

import { observable } from "mobx";

class Store {
  @observable theme =  'No theme set';
}

const store = new Store();

Jeżeli pole klasy udekorujemy za pomocą @observable, przypisanie do niego nowej wartości poinformuje o zmianie wszystkich subskrybentów. Utwórzmy teraz klasę która zasubskrybuje do zmian:

import { observer } from "mobx-react"

@observer
class ThemeInfoClass extends React.Component {
  render() {
    const { store } = this.props;
    return (
      <div>
        <p>
          The theme is {store.theme} (class)
        </p>
        <Buttons store={store}/>
      </div>
    )
  };
}

Udekorowanie klasy za pomocą @observer powoduje opakowanie metody render dodatkowym kodem, który spowoduje reakcję na zmiany. Może być to również komponent funkcyjny, wtedy jednak nie możemy użyć observer jako dekoratora tylko jako funkcję:

const ThemeInfo = observer(({store}) => {
return (
<div>
<p>
The theme is {store.theme}
</p>
<Buttons store={store}/>
</div>
);
});

W komponencie Buttons dokonujemy natomiast przypisania nowej wartości do store:

const Buttons = ({store}) => {
return (
<>
<button onClick={() => store.theme = 'dark'}>Dark</button>
<button onClick={() => store.theme = 'light'}>Light</button>
</>
)
};

Dla uproszczenia store przekazywany jest bezpośrednio, nic nie stoi jednak na przeszkodzie, by przekazywać store poprzez omówiony wcześniej React Context – zalecają to sami twórcy MobX.

MobX – pełny przykład

import React from 'react';
import ReactDOM from 'react-dom';

import { observer } from "mobx-react"
import { observable } from "mobx"

@observer
class ThemeInfoClass extends React.Component {
render() {
const { store } = this.props;
return (
<div>
<p>
The theme is {store.theme} (class)
</p>
<Buttons store={store}/>
</div>
)
};
}

const ThemeInfo = observer(({store}) => {
return (
<div>
<p>
The theme is {store.theme}
</p>
<Buttons store={store}/>
</div>
);
});

class Store {
@observable theme = 'No theme set';
}

const store = new Store();

const Buttons = ({store}) => {
return (
<>
<button onClick={() => store.theme = 'dark'}>Dark</button>
<button onClick={() => store.theme = 'light'}>Light</button>
</>
)
};

const App = () => {
return (
<>
<ThemeInfo store={store} />
<ThemeInfoClass store={store} />
</>
);
};

ReactDOM.render(<App />, document.getElementById("root"));

Zalety stosowania MobX

  1. Mały boilerplate
  2. Renderuje tylko to, co naprawdę się zmieniło
  3. Nie trzeba dostosowywać do niego całej architektury aplikacji

Wady stosowania MobX

  1. Nie wspiera domyślnie immutability, nie jest stworzona z tą koncepcją jako główną
  2. Jest trudna w debugowaniu

MobX State Tree

MobX State Tree to biblioteka bazująca na MobX, która do zalety MobX jaką jest możliwość obserwowania zmian dodaje zalety związane z podejściem immutability – mimo że takiego podejścia wprost nie implementuje, stan jest mutowany ale tworzony jest z niego immutable snapshot.

Instalacja i użycie MobX State Tree

Po zainstalowaniu biblioteki poleceniami:

 npm install mobx mobx-state-tree --save 

Możemy utworzyć pierwszy model:

import { types } from "mobx-state-tree";

const Theme = types.model({
value: 'Not set'
});

Dzięki temu że model ma określony interfejs, przy tworzeniu instancji będzie rzucał błędem jeżeli pojawią się pola nieprzewidziane w interfejsie:

const theme = Theme.create({
  value: 'No value',
});

// Rzuci błąd
const theme2 = Theme.create({
  value: 'No value',
  active: false,
});

Zmiana wartości modelu odbywa się poprzez akcje, które deklarujemy przy tworzeniu modelu:

const Theme =
types.model({
value: 'Not set'
})
.actions(self => {
return {
setTheme(value) {
self.value = value;
}
}
});

Samo użycie modelu MST wygląda jak użycie store z MobX – poza tym oczywiście, że aktualizujemy nazwy pól i sposób w jaki ustawiamy wartość modelu:

const Theme =
  types.model({
    value: 'Not set'
  })
  .actions(self => {
    return {
      setTheme(value) {
        self.value = value;
      }
    }
  });

const store = Theme.create({
  value: 'No value',
});

...
<p>
  The theme is {store.value}
</p>
...
<button onClick={() => store.setTheme('dark')}>Dark</button>
...

MobX State Tree – pełny przykład

import React from 'react';
import ReactDOM from 'react-dom';
import { observer } from "mobx-react";
import { types } from "mobx-state-tree";

const Theme =
types.model({
value: 'Not set'
})
.actions(self => {
return {
setTheme(value) {
self.value = value;
}
}
});

const store = Theme.create({
value: 'No value',
});


const ThemeInfo = observer(({store}) => {
return (
<div>
<p>
The theme is {store.value}
</p>
<Buttons store={store}/>
</div>
);
});


const Buttons = ({store}) => {
return (
<>
<button onClick={() => store.setTheme('dark')}>Dark</button>
<button onClick={() => store.setTheme('light')}>Light</button>
</>
)
};

const App = () => {
return (
<>
<ThemeInfo store={store} />
</>
);
};

ReactDOM.render(<App />, document.getElementById("root"));

Zalety stosowania MobX State Tree

  1. Posiada wszystko co ciekawe w Redux – akcje, selektory itd
  2. Wbudowana memoizacja
  3. Większy porządek niż w przypadku zwykłego MobX
  4. Łatwa możliwość zaimplementowania cofania się w czasie dzięki posiadaniu zalet podejścia immutability

Wady stosowania MobX State Tree

  1. Trochę boilerplate – modele, akcje itd..

Reusable

Reusable bazuje na nieco innym podejściu – umożliwia utworzenie singletona z customowego hooka z lokalnym stanem z useState.

Instalacja i użycie Reusable

Po zainstalowaniu biblioteki poleceniem:

npm install reusable

Przekażmy do funkcji createStore customowy hook zarządzający stanem:

// Lokalny
const useThemeHook = () => {
const [theme, setTheme] = useState('Not set');

return {
theme,
setTheme,
};
};

// Singleton
const useTheme = createStore(useThemeHook);

Wartości z hooka useTheme są globalne. Mimo tego że odczytujemy je jak zwyczajne hooki lokalne, wartości są aktualizowane w całej aplikacji:

const { theme } = useTheme();
return (
  <div>
    <p>
      The theme is {theme}
    </p>
    <Buttons/>
  </div>
);
...
const {setTheme} = useTheme();

return (
  <>
    <button onClick={() => setTheme('dark')}>Dark</button>
    <button onClick={() => setTheme('light')}>Light</button>
  </>
)

Reusable – pełny przykład

import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import {createStore, ReusableProvider} from "reusable";

// Lokalny
const useThemeHook = () => {
const [theme, setTheme] = useState('Not set');

return {
theme,
setTheme,
};
};

// Singleton
const useTheme = createStore(useThemeHook);


const ThemeInfo = () => {
const { theme } = useTheme();
return (
<div>
<p>
The theme is {theme}
</p>
<Buttons/>
</div>
);
};


const Buttons = () => {
const {setTheme} = useTheme();

return (
<>
<button onClick={() => setTheme('dark')}>Dark</button>
<button onClick={() => setTheme('light')}>Light</button>
</>
)
};

const App = () => {
return (
<ReusableProvider>
<ThemeInfo />
</ReusableProvider>
);
};

ReactDOM.render(<App />, document.getElementById("root"));

Zalety stosowania Reusable

  1. Zdecydowanie najmniej kodu ze wszystkich omawianych przykładów
  2. Fakt globalnego stanu nie wpływa na architekturę aplikacji

Wady stosowania Reusable

  1. Biblioteka jest bardzo młoda, nieprzetestowana w boju.

Podsumowanie

Statystyki popularności są bezlitosne:

  1. React Redux: ponad 650 tysięcy pobrań
  2. MobX: 70 tysięcy pobrań
  3. MobX State Tree: poniżej 10 tysięcy pobrań
  4. Resuable: poniżej 200 pobrań

Redux może więc spać spokojnie, dzierżąc palmę pierwszeństwa jeśli chodzi o popularność wśród bibliotek zarządzania stanem. Dobrze jest jednak wiedzieć, że istnieją alternatywy – w jakimś projekcie można na nie trafić, ponadto mogą być fajną ciekawostką wartą wspomnienia na rozmowie kwalifikacyjnej.