Jak możesz przeczytać w tytule dzisiejszego wpisu, jego tematem będzie zarządzanie stanem aplikacji ReactJS. Przedstawię w nim podstawy Redux czyli jednej z najpopularniejszych implementacji architektury Flux. Jeśli zagadnienia te są dla Ciebie zupełnie obce to mam nadzieję, że wszystkie Twoje wątpliwości zostaną rozwiane po przeczytaniu tego wpisu!

Ale do rzeczy… Jedną z najważniejszych cech komponentów ReactJS jest wbudowany w nie stan. Jak mogłeś się dowiedzieć z wpisu na temat podstaw ReactJS, jest to bardzo przydatny koncept. Komponent posiada stan, który może ulec zmianie w wyniku interakcji użytkownika z aplikacją. Zmiana stanu pociąga za sobą operację re-renderowania drzewa Virtual DOM. W wyniku tego, pewne części interfejsu widocznego na ekranie ulegają zmianie. Oczywiście wiesz już też, że jeden komponent może zależeć od innego komponentu. Możemy przecież przekazywać stan komponentu rodzica do jego komponentów dzieci itd.

To wszystko działa świetnie. Niestety w miarę jak aplikacja rośnie, rozrasta się też poziom skomplikowania zależności poszczególnych komponentów. Z tego też powodu, programiści Facebooka odpowiedzialni za rozwój ReactJS wymyślili architekturę aplikacji, która rozwiązuje ten problem. Architekturę tę nazwano Flux. Jak już wspomniałem, dziś przedstawię Ci tę architekturę oraz podstawy Redux czyli jednej z jej najlepszych, moim zdaniem, implementacji.

Co to jest architektura Flux?

Ogólnie rzecz biorąc Flux to tylko koncepcja architektury. Nie jest żadną konkretną biblioteką czy też frameworkiem. Poniżej przedstawiam co na ten temat napisali jej autorzy:

Flux is the application architecture that Facebook uses for building client-side web applications. It complements React’s composable view components by utilizing a unidirectional data flow. It’s more of a pattern rather than a formal framework, and you can start using Flux immediately without a lot of new code.

Jak widzisz jedyną rzeczą, którą architektura Flux dodaje do ReactJS jest “jednokierunkowy przepływ danych” (ang. unidirectional data flow). Ale co to właściwie znaczy? Myślę, że świetnie wyjaśnia to poniższy diagram, zaczerpnięty z oficjalnej dokumentacji Flux:

architektura Flux - diagram

Objaśnienie diagramu

Przeanalizujmy dokładniej przedstawiony diagram. Przepływ rozpoczyna się od lewej strony. Najpierw tworzona jest akcja - jest to de facto zwykły literał obiektu zawierający właściwość type. Oprócz tego może on posiadać więcej właściwości służących do przekazywania dodatkowych danych. Akcja taka tworzona jest przez funkcję zwaną z angielska action creator czyli po naszemu kreator akcji.

Tak utworzona akcja dostarczana jest do store za pomocą wywołania funkcji nazywanej dispatcher. Funkcja ta w zasadzie zarządza całym przepływem danych. Każdy store w aplikacji rejestruje w tym dispatcherze swoje funkcje wywołania zwrotnego w celu obsługi przychodzących akcji. W momencie gdy akcja jest rozsyłana (ang. dispatch), wywoływane są po kolei wszystkie te callbacki. Jeden z nich powinien umieć rozpoznać akcję po jej typie i być przygotowany na jej odpowiednią obsługę.

Generalnie wszystkie obiekty store zawierają łącznie cały stan aplikacji. Tak jak napisałem przed chwilą, każdy store zawiera implementację funkcji wywołania zwrotnego, która jest rejestrowana w “dispatcherze”, i która obsługuje akcje związane z danym store.

Kolejny element na diagramie to widok. Można powiedzieć, że jest on reprezentowany po prostu przez komponent ReactJS. Używa on stanu aplikacji zapisanego w obiekcie store tak jakby był on wewnętrznym stanem komponentu. Zmiana stanu w store powoduje re-renderowanie komponentu (to jest miejsce gdzie ReactJS wkracza do gry). Dodatkowo, komponent widoku może “dispatchować” kolejne akcje, na przykład kiedy użytkownik kliknie jakiś guzik na ekranie. To powoduje zmianę stanu zapisanego w store itd.

Wszystko to, to po prostu zestaw zasad. To programista może zdecydować jak to dokładnie będzie zaimplementowane. Na szczęście nie jesteśmy zostawieni sami sobie. Istnieje już kilka gotowych implementacji architektury Flux w postaci bibliotek. Jako, że tematem dzisiejszego artykułu są podstawy Redux, to domyślasz się pewnie, że jest on właśnie jedną z takich bibliotek. A zatem przejdźmy do sedna…

Podstawy Redux w pigułce

Właściwie to Redux nie jest dokładną implementacją architektury Flux. Jest nią jednak silnie inspirowany. Dodatkową inspiracją dla autora Reduxa była architektura stosowana w języku Elm. Osobiście jest to moja ulubiona implementacja Flux ponieważ, trochę ją ona upraszcza. Redux nie posiada koncepcji “dispatchera” a store implementowany jest jako czysta funkcja zwana “reducerem”.

Jeśli wciąż nie jesteś przekonany dlaczego Redux to najlepsza implementacja Flux, możesz przeczytać świetny wpis jego autora, Dana Abramova, na stack overflow, który rozwiewa wszelkie wątpliwości.

Fundamentalne zasady Reduxa

No dobra, dość pitolenia. Zacznijmy wreszcie omawiać podstawy Redux! Jeśli zajrzysz do oficjalnej dokumentacji Reduxa znajdziesz tam informację mówiącą, że Redux może być opisany przez trzy fundamentalne zasady:

  • Pojedyncze źródło prawdy - stan całej aplikacji przetrzymywany jest w drzewie obiektów wewnątrz pojedynczego obiektu store
  • Stan jest tylko do odczytu - jedynym sposobem na zmianę stanu jest wywołanie akcji, która zwraca obiekt opisujący co powinno się stać
  • Zmiany wykonywane są w ramach czystych funkcji - aby określić jak drzewo stanu transformowane jest przez akcje musisz tworzyć “czyste reducery”

Zanim przejdziemy dalej, jeszcze jedna uwaga: generalnie Redux nie jest jakoś super uzależniony od ReactJS. W zasadzie można go używać również z innymi frameworkami JavaScript. Sam widziałem przykład użycia Reduxa w aplikacji Angular 2. Ja jednak przedstawię podstawy Redux w kontekście właśnie z ReactJS, ponieważ staram się przedstawić Ci cały jego “ekosystem”.

Przykład wykorzystania Reduxa

Skoro znamy główne zasady Reduxa, możemy zacząć analizę kodu dzięki czemu łatwiej będzie nam zrozumieć podstawy Redux. Na szczęście możemy łatwo znaleźć mnóstwo dobrych przykładów, w kodzie źródłowym Reduxa dostępnym jako repozytorium GitHub. Myślę, że najlepiej będzie zacząć od przykładu możliwie najprostszego, a takim jest counter-vanilla. Niestety nie wykorzystuje on ReactJS, a chciałbym w tym właśnie kontekście omówić podstawy Redux. Dlatego też na bazie tego przykładu stworzyłem własny, który również dostępny jest jako repozytorium GitHub: react-redux-example.

Rzućmy okiem na plik index.js dostępny w ramach tego repo:

import React, { PropTypes } from 'react';
import { render } from 'react-dom';
import { createStore } from 'redux';
import { connect, Provider } from 'react-redux';

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, counter: state.counter + 1 };
    case 'DECREMENT':
      return { ...state, counter: state.counter - 1 };
    default:
      return state;
    }
};

const store = createStore(reducer, { counter: 0 });

class Counter extends React.Component {
  static propTypes = {
    counter: PropTypes.Number,
    onIncrement: PropTypes.func,
    onDecrement: PropTypes.func
  };

  render() {
    const { counter, onDecrement, onIncrement } = this.props;

    return (
      <div>
        <div>{counter}</div>
        <button onClick={onDecrement}>-</button>
        <button onClick={onIncrement}>+</button>
      </div>
    );
  }
}

const mapStateToProps = (state) => {
  return { counter: state.counter };
};
const mapDispatchToProps = (dispatch) => {
  return {
    onIncrement: () => dispatch({ type: 'INCREMENT' }),
    onDecrement: () => dispatch({ type: 'DECREMENT' })
  }
};

Counter = connect(mapStateToProps, mapDispatchToProps)(Counter);

render(
  <Provider store={store}>
    <Counter />
  </Provider>
  , document.getElementById('root')
);

No dobra, przykład jest dość obszerny. Omówię więc po kolei poszczególne jego części.

Reducer

Pierwszym interesującym kawałkiem powyższego kodu jest funkcja o nazwie reducer:

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
  return { ...state, counter: state.counter + 1 };
    case 'DECREMENT':
  return { ...state, counter: state.counter - 1 };
    default:
  return state;
  }
};

Tak jak już wcześniej napisałem, funkcja reducer pełni rolę obiektu store architektury Flux. Dzięki temu, że jest to funkcja, można ją jednocześnie użyć jako funkcję wywołania zwrotnego, którą store uruchomi w momencie gdy zostanie rozgłoszona jakaś akcja. Funkcja ta przyjmuje dwa parametry: state oraz action.

Generalnie działa to tak: ktoś wywołuje akcję, obiekt store wywołuje funkcję reducer, przekazując do niej aktualny stan oraz akcję, funkcja reducer sprawdza typ przekazanej do niej akcji i w zależności jak jest ten typ, zwraca nową wersję obiektu stanu.

W przykładzie, jeśli typ akcji to “INCREMENT” to zwracany jest nowy obiekt stanu, który ma zwiększoną o jeden wartość atrybutu counter. Jeśli natomiast typ akcji to “DECREMENT” to zwracany jest nowy stan z atrybutem counter zmniejszonym o jeden. Jeśli natomiast typ akcji jest zupełnie inny, obiekt stanu jest po prostu przekazywany dalej - prawdopodobnie obsługuje go inny reducer.

To na co jeszcze trzeba tutaj zwrócić uwagę to to, że stan musi być zawsze niezmienny (ang. immutable). To właśnie dlatego zawsze zwracamy nowy obiekt stanu. Zwróć uwagę na operator spread użyty w przykładzie:

return { ...state, counter: state.counter + 1 };

Znaczy to, że zwracamy nowy obiekt, który zawiera wszystkie właściwości skopiowane z obiektu state, a dodatkowo właściwość state.counter jest zmieniona.

Tworzenie store

Przejdźmy dalej. Kolejna porcja kodu do analizy to:

const store = createStore(reducer, { counter: 0 });

Funkcja createStore jest dostarczana przez bibliotekę Redux. Służy ona do utworzenia obiektu store. Przyjmuje ona reducer jako pierwszy parametr (funkcja callback) oraz stan początkowy aplikacji jak drugi parametr. Podczas tworzenia obiektu store Redux rejestruje w nim funkcję reducer. Jak już wspomniałem będzie ona wywoływana przez obiekt store w momencie rozgłoszenia akcji.

Komponent ReactJS

Kolejna rzecz w naszym przykładzie, to komponent ReactJS. Poniżej jeszcze raz jego implementacja:

class Counter extends React.Component {
  static propTypes = {
    counter: PropTypes.Number,
    onIncrement: PropTypes.func,
    onDecrement: PropTypes.func
  };

  render() {
    const { counter, onDecrement, onIncrement } = this.props;

    return (
      <div>
        <div>{counter}</div>
        <button onClick={onDecrement}>-</button>
        <button onClick={onIncrement}>+</button>
      </div>
    );
  }
}

Myślę, że po przeczytaniu mojego wpisu na temat podstaw ReactJS, powyższy kod nie powinien być już dla Ciebie niczym nowym. Jeśli coś jest niejasne to odsyłam do tamtego artykułu. Z punktu widzenia Reduxa, jedyną interesującą tutaj rzeczą jest to, że obiekt this.props zawiera właściwość counter oraz metody onDecrement oraz onIncrement! To jest właśnie klucz do tego, jak Redux współpracuje z ReactJS.

Właściwość counter jest mapowana bezpośrednio ze stanu “reducera” więc każda zmiana tej wartości zainicjowana przez wywołanie odpowiedniej akcji będzie odpowiednio rzutować na interfejs użytkownika. Metody onDecrement oraz onIncrement wywołują funkcję dispatch (o tym więcej za chwilę) powodując rozgłoszenie odpowiedniej akcji do obiektu store.

Mapowanie stanu oraz funkcji dispatch do “propsów”

Przed momentem odkryliśmy, że stan oraz funkcje rozsyłające akcje dostępne są w obiekcie this.props. Oto jak się to dzieje - najpierw spójrzmy na kod, który jest odpowiedzialny za ich mapowanie:

const mapStateToProps = (state) => {
  return { counter: state.counter };
};
const mapDispatchToProps = (dispatch) => {
  return {
    onIncrement: () => dispatch({ type: 'INCREMENT' }),
    onDecrement: () => dispatch({ type: 'DECREMENT' })
  }
};

Counter = connect(mapStateToProps, mapDispatchToProps)(Counter);

Funkcja mapStateToProps

Funkcja mapStateToProps pobiera state jako parametr i zwraca nowy obiekt. Częstą praktyką jest po prostu przekazanie całego stanu do “propsów” ale jeśli chcesz odfiltrować jakieś dane itp., to to jest na to właściwe miejsce. Ja w powyższym przykładzie ograniczam to co ma zostać zmapowane tylko do właściwości counter.

Funkcja mapDispatchToProps

Kolejna funkcja to mapDispatchToProps. Jak widzisz, zwraca ona obiekt zawierający metody. Za pomocą wywołania funkcji dispatch rozgłaszają one obiekty akcji do store. W powyższym przykładzie mamy pewne uproszczenie: do metody dispatch przekazuję obiekty akcji bezpośrednio. Zwykle w projekcie definiuje się do tego specjalne funkcje - tak zwane kreatory akcji (ang. action creators). Przykładowo, taki kreator akcji mógłby wyglądać jak poniżej:

function increment(value) {
  return {
    type: 'INCREMENT',
    payload: value
  }
}

Później w funkcji mapStateToProps przekazujemy do funkcje dispatch wynik wywołania takiego kreatora akcji. Oczywiście, tak jak już wspomniałem, za pomocą akcji można też przekazywać dodatkowe dane. W powyższym przykładzie funkcja increment przyjmuje parametr value, który przekazywany jest dalej w obiekcie akcji. Wartość ta może zostać następnie wykorzystana przez reducer obsługujący daną akcję.

Funkcja connect

Ale wróćmy do tematu mapowania. W przedstawionym wcześniej przykładzie najbardziej istotna jest ostatnia jego linia. Jak widzisz, wywołuję w niej funkcję connect. Przyjmuje ona funkcje mapStateToProps oraz mapDispatchToProps jako parametry i wyniki ich wywołania łączy w odpowiedni obiekt. Następnie zwraca ona funkcję, która jako parametr przyjmuje komponent. Funkcja ta wstrzykuje przygotowany wcześniej obiekt do this.props tego komponentu.

Zwróć też uwagę, że ostateczny wynik opisanych działań jest przypisywany ponownie do obiektu komponentu. To dlatego, że funkcja zwracana przez funkcję connect opakowuje przekazany komponent i zwraca nową jego wersję.

Renderowanie

Ostatnia rzecz to renderowanie wszystkiego na ekranie. Robimy to standardowo za pomocą funkcji render należącej do pakietu react-dom:

render(
  <Provider store={store}>
    <Counter />
  </Provider>
  , document.getElementById('root')
);

Jak widzisz, by wszystko zadziałało jak należy musimy “owinąć” nasz komponent Counter komponentem Provider dostarczanym przez moduł react-redux. Podobnie jak robiliśmy to w przypadku react-router - sprawdź ostatni wpis na temat routingu ReactJS. Jako parametr musimy przekazać mu utworzony wcześniej obiekt store to spowoduje, że wszystko co do tej pory zrobiliśmy zacznie działać.

Podstawy Redux - podsumowanie

To tyle jeśli chodzi o podstawy Redux. Wydaje mi się, że przedstawiony przykład jest wystarczający aby zrozumieć zasadę działania Reduxa - co do czego, z czym i po co…

To jednak nie koniec tego tematu. W kolejnym wpisie planuję rozwinąć temat Redux i pokazać Ci, między innymi, jak rozdzielić store na więcej “reducerów”, jak użyć biblioteki Immutable.js do przechowywania stanu albo jak można lepiej użyć funkcji connect. Zapraszam!

Kod przykładu zawartego w artykule

Pełen kod przedstawionych dziś przykładów dostępny jest w repozytorium GitHub. Wystarczy, że sklonujesz repozytorium react-redux-example. Po jego sklonowaniu, nie zapomnij uruchomić npm install w celu zainstalowania wszystkich wymaganych zależności!

A może chciałbyś lepiej poznać Reduxa?

Powyższy wpis to tylko niezbędne minimum, które trzeba znać aby zacząć pracować z Reduxem. Jeśli chciałbyś dogłębnie i od podstaw poznać tę bibliotekę to specjalnie dla Ciebie przygotowałem specjalne kursy on-line, dzięki którym od podstaw nauczysz się Reduxa, ale też Reacta oraz react-routera! Kliknij poniżej aby dowiedzieć się więcej:

React, Redux, react-router - kursy on-line