W poprzednim wpisie przedstawiłem Ci absolutne podstawy Redux. Dowiedziałeś się z niego na czym polega architektura Flux oraz przedstawiłem Ci podstawy jednej z najlepszych jego implementacji. Na końcu tamtego artykułu napisałem, że w kolejnym wpisie przedstawię trochę ulepszeń dla Redux. Nie lubię rzucać słów na wiatr, dlatego też dziś przedstawiam wpis, w którym pokażę różne usprawnienia Redux, które spowodują, że praca z aplikacją ReactJS będzie łatwiejsza… Myślę, że nie ma co przedłużać tego wstępu - przejdźmy więc do rzeczy!

P.S. Do pełnego zrozumienia tego wpisu potrzebna jest wcześniejsza znajomość Reduxa. Jeśli nie do końca wiesz co to jest, przeczytaj najpierw mój wpis na temat podstaw Redux!

Immutable.js - najlepszy sposób na niezmienny stan Redux

Myślę, że najlepiej będzie najważniejsze usprawnienia Redux opisać na początku…

Biblioteka Immutable.js to kolejny projekt open-source od Facebooka. Jak sama nazwa wskazuje służy ona do zapewniania niezmienności danych. To wzbudza podejrzenie, że idealnie będzie ona współgrać z Reduxem, w którym przecież stan aplikacji również powinien być obiektem niezmiennym. Zresztą za chwilę się o tym przekonasz.

Jeszcze raz… co to jest Immutable.js?

Dla porządku, poniżej przedstawiam cytat z oficjalnej dokumentacji Immutable.js, który objaśnia czym tak na prawdę jest ta biblioteka:

Immutable data cannot be changed once created, leading to much simpler application development, no defensive copying, and enabling advanced memoization and change detection techniques with simple logic. Persistent data presents a mutative API which does not update the data in-place, but instead always yields new updated data.

Immutable.js provides many Persistent Immutable data structures including: List, Stack, Map, OrderedMap, Set, OrderedSet and Record.

Jak więc widzisz, dane niezmienne (ang. immutable) nie mogą być już modyfikowane po ich utworzeniu. Wpływa to korzystnie na uproszczenie procesu developmentu oraz pozwala na wprowadzenie technik wykrywania zmian, które posiadają prostą logikę. Niezmienne obiekty Immutable.js posiadają specjalne API, które pozwala na manipulację danymi. Jednak same dane nie ulegają zmianie, są przecież niezmienne. Zamiast tego wywołanie metody API, na przykład set, powoduje zwrócenie nowej wersji niezmiennego obiektu, która zawiera wymagane zmiany.

Przykład wykorzystania Immutable.js

Hmm… brzmi jak coś co idealnie przyda się w Redux, prawda? Myślę, ze czas przejść do zaprezentowania przykładu kodu z użyciem biblioteki Immutable.js (na razie bez kontekstu Reduxa):

var value = Immutable.Record({ key: 1 });
console.log(value.get('key')); // 1

value.set('key', 2);
console.log(value.get('key')); // wciąż 1

value = value.set('key', 2); // należy przypisać!
console.log(value.get('key')); // 2

W powyższym przykładzie użyłem obiektu Record należącego do biblioteki Immutable.js. Struktura Record jest nieposortowaną kolekcją par klucz-wartość. To nie przypadek, że użyłem właśnie tego obiektu. Użyjemy go w dalszej części artykułu, gdzie przedyskutujemy wykorzystanie Immutable.JS razem z Reduxem.

Najważniejsze w zaprezentowanym przykładzie są linie 4 oraz 5. Jak widzisz, wykorzystałem wspomnianą przed chwilą metodę set, należącą do API Immutable.js, do zmiany wartości key na 2 (linia czwarta). W kolejnej linii możesz zauważyć, że obiekt value nie uległ zmianie - wartość jego właściwości key wciąż wynosi 1.

Jeśli teraz spojrzysz na linię numer 7 zauważysz, że w tym przypadku przypisuję rezultat wywołania metody set do obiektu value. Tak jak wspomniałem wcześniej, metody API nie zmieniają obiektu. Zamiast tego zwracają jego nową wersję dlatego też takie ponowne przypisanie jest konieczne. Na dowód, w linii numer 8 sprawdzam wartość klucza po ponownym przypisaniu obiektu value. Jak widzisz, zgodnie z moją intencją tym razem jest to wartość 2.

Myślę, że ten przykład wystarczy by zrozumieć zasadę działania biblioteki Immutable.js. Inne dostępne w niej struktury danych działają na tej samej zasadzie. Czas wreszcie na jakieś usprawnienia Redux - pierwszym z nich będzie użycie Immutable.js!

Usprawnienia Redux - stan aplikacji jako obiekt Immutable.js

No dobra, przejdźmy do sedna. Jak już pisałem w poprzednim moim artykule, a także wspominałem wcześniej tutaj, stan aplikacji w Redux powinien być niezmienny. Za każdym razem gdy funkcja reducer musi zmienić stan, tak na prawdę zwraca ona nowy obiekt stanu, który zawiera niezbędne modyfikacje. Aby ten proces ulepszyć warto wykorzystać opisywaną przeze mnie bibliotekę Immutable.js.

Myślę, że najlepiej będzie pokazać to po prostu na przykładzie. Spójrzmy najpierw na reducer z mojego poprzedniego wpisu:

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 });

Teraz możemy to bardzo łatwo przepisać na użycie Immutable.js:

const initialState = Immutable.Record({ counter: 0 });

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

const store = createStore(reducer, initialState);

Przeanalizujmy powyższy przykład… W pierwszej linii definiuję nową zmienną - initialState. Do zmiennej tej przypisuję strukturę danych Immutable.Record. Dodatkowo inicjuję ją od razu obiektem, który posiada właściwość counter o wartości 0. Rzuć teraz okiem na ostatnią linię przykładu. Obiekt ten przekazuję do metody createStore jako jej drugi parametr. Od tego momentu stan aplikacji jest obiektem Record.

To co jeszcze jest tutaj ważne to to, że teraz aby dokonać zmiany stanu aplikacji możemy, czy raczej jesteśmy zmuszeni, użyć API Immutable.js. W liniach numer 6 oraz 8 widać użycie metody set API. Jak już wiemy to spowoduje zwrócenie nowej, zaktualizowanej wersji obiektu stanu.

Uzyskaliśmy więc dokładnie to samo co w poprzednim przykładzie tyle, że z użyciem bezpieczniejszego podejścia. Dzięki Immutable.js stan nie może zostać przypadkowo zmieniony - aby to zrobić zawsze jesteś zmuszony użyć API.

I to wszystko! Pierwsze usprawnienia Redux za nami! Proste prawda?

Reducery i akcje w wielu plikach

Ok, czas teraz pokazać kolejne usprawnienia Redux. Z poprzedniego wpisu na temat podstaw Redux dowiedziałeś się, że obowiązuje nas jedno jedyne źródło prawdy czyli, że “stan całej aplikacji przetrzymywany jest w drzewie obiektów wewnątrz pojedynczego obiektu store”. Nie znaczy to jednak, że musimy koniecznie mieć jeden wielki reducer i trzymać go w jednym pliku. Podobnie sprawa ma się w przypadku kreatorów akcji. Dobrą praktyką jest rozbić je sobie na mniejsze pliki, najczęściej odpowiadające poszczególnym obszarom aplikacji.

Rozbicie reducerów na mniejsze funkcje

Jak zawsze u mnie bywa, najlepiej jest przedstawić przykład a później go omówić. Załóżmy, że mamy poniższy reducer znany już z poprzedniego wpisu:

const initialState = Immutable.Record({ counter: 0 });

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

export default counterReducer;

Oczywiście jest już on usprawniony za pomocą Immutable.js. Przyjmijmy teraz, że w naszej aplikacji mamy inny obszar, na przykład inną podstronę w routingu. Moglibyśmy chcieć wydzielić dla niej osobny reducer:

const initialState = Immutable.Record({ name: '',  age: 0 });

const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'SET_NAME':
      return state.set('name', action.name);
    case 'SET_AGE':
      return state.set('age', action.age);
    default:
      return state;
   }
};

export default userReducer;

Zwróć uwagę, że zarówno w jednym jak i drugim przypadku, parametr state funkcji reducera jest teraz inicjowany stałą initialState. Za chwilę dowiesz się, dlaczego robię to w ten właśnie sposób.

Łączenie reducerów

No dobra, wszystko fajnie ale przecież funkcja createStore przyjmuje jako parametr tylko jeden reducer tak? W zasadzie to tak, w poprzednim wpisie pokazywałem przecież taki przykład tworzenia obiektu store:

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

Na szczęście Redux przychodzi nam z pomocą dostarczając nam specjalną funkcję combineReducers, która pozwala na połączenie naszych “reducerów” w jeden obiekt. Zostanie on następnie prawidłowo obsłużony przez funkcję createStore:

import { combineReducers, createStore } from 'redux';

import counterReducer from './counterReducer';
import userReducer from './userReducer';

const reducers = combineReducers({
  counterReducer,
  userReducer
});

const store = createStore(reducers, {});

Jak widzisz, funkcję combineReducers importuję z pakietu redux. Wywołując ją przekazuję mu obiekt zawierający właściwości, do których przypisuję poszczególne “reducery”. Zwróć uwagę na zapis bez dwukropka - jest to nowy skrócony zapis dostępny w ES6. Powoduje on utworzenie właściwości o tej samej nazwie co wartość do niej przypisywana z jednoczesnym jej przypisaniem.

Dodatkowo zwróć też uwagę na ostatnią linię przykładu. Jak widzisz, jako stan początkowy przekazuję pusty obiekt. To dlatego, że teraz mam stan rozrzucony po kilku plikach. Nie jest to jednak problem ponieważ każda część stanu jest inicjowana w poszczególnych plikach/modułach reducerów w momencie utworzenia obiektu Immutable.js - przypisanie state = initialState, o którym wcześniej wspomniałem. Zgodnie z tym jak działają moduły ES6 każda z tych inicjalizacji nastąpi w momencie załadowania danego modułu.

Grupowanie kreatorów akcji w osobnych plikach

Nie tylko “reducery” można rozbijać na mniejsze moduły. To samo można zrobić z kreatorami akcji. Zwykle stosuje się podejście, w którym dla każdego modułu “reducera” tworzy się osobny moduł zawierający kreatory akcji. W przypadku przedstawionych powyżej dwóch “reducerów” mielibyśmy następujące moduły kreatorów akcji:

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';

export function increment() {
  return {
    type: INCREMENT
  };
}

export function decrement() {
  return {
    type: DECREMENT
  }
}

… oraz …

export const SET_NAME = 'SET_NAME';
export const SET_AGE = 'SET_AGE';

export function setName(name) {
  return {
    type: SET_NAME,
    name
  }
}

export function setAge(age) {
  return {
    type: SET_AGE,
    age
  }
}

W powyższych przykładach zwróć uwagę, że eksportuję również stałe, których wartości przypisywane są do właściwości type zwracanych obiektów. Jest to dobra praktyka, aby posługiwać się w tym przypadku stałymi. Można wtedy uniknąć literówek. Popularną praktyką jest trzymanie wszystkich tych stałych w osobnym pliku consts. Dzięki temu unikniesz przypadkowego utworzenia dwóch stałych o tej samej nazwie…

Tak utworzone moduły importuje się zwykle później w modułach odpowiadających im “reducerów”. Dzięki temu możesz skorzystać ze zdefiniowanych tutaj stałych właściwości type. Poza tym, importuje się je w miejscu definicji funkcji mapDispatchToProps - wspominałem o tym w poprzednim wpisie. Za chwilę będę omawiać usprawnienia Redux dotyczące funkcji mapujących więc nie ma sensu dalej rozwijać tutaj tego tematu.

Łączenie w jedną tablicę

Zanim przejdziemy do upraszczania funkcji mapujących, warto sobie wszystkie funkcje kreatorów akcji zgrupować w tablicy (przyda nam się to za chwilę):

import * as counterActions from './counterActions';
import * as userActions from './userActions';

const actions = [
  counterActions,
  userActions
];

return default actions;

Uproszczenie funkcji mapujących

Jak już przed momentem wspomniałem, opisywane dziś przeze mnie usprawnienia Redux obejmują też uproszczenie funkcji mapujących mapStateToProps oraz mapDispatchToProps. Najlepiej jest te funkcje wyciągnąć do osobnych modułów/plików. Spójrzmy jak może wyglądać ich implementacja…

Funkcja mapStateToProps

Tutaj sprawa jest prosta. Funkcja ta zwykle po prostu przekazuje dalej obiekt statnu:

export default function mapStateToProps(state) {
  return {
    ...state
  };
}

Zwróć uwagę, że wykorzystuję tutaj operator spread. Dzięki niemu nie muszę po kolei wypisywać wszystkich właściwości obiektu state.

Dzięki takiej implementacji funkcji mapStateToProps cały stan aplikacji dostępny jest w komponentach ReactJS. Biorąc pod uwagę nasz przykład “reducera” counterReducer, byłby on dostępny w komponencie poprzez this.props.counterReducer. To wszystko dzięki funkcji combineReducers, której użyliśmy wcześniej w tym artykule.

Funkcja mapDispatchToProps

W poprzednim wpisie funkcja ta wyglądała mniej więcej tak:

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

Teraz jednak wprowadziliśmy do obiegu funkcje kreatorów akcji. Przenieśmy więc powyższą funkcję do osobnego pliku i stwórzmy nowy moduł korzystający z naszych kreatorów akcji:

import * as counterActions from './counterActions';

const mapDispatchToProps = (dispatch) => {
  return {
    onIncrement: () => dispatch(counterActions.increment()),
    onDecrement: () => dispatch(counterActions.decrement())
  }
}

export default mapDispatchToProps;

Jak widzisz, tym razem użyłem kreatorów akcji do utworzenia obiektów akcji, które następnie przekazuję do funkcji dispatch.

Uproszczenie

Tyle, że to nadal mnóstwo kodu do napisania. Z każdym razem gdy dodajesz nową akcję, tutaj też musisz dodać nową funkcję wywołującą funkcję dispatch. Można to na szczęście uprościć:

import { Map } from 'immutable';
import { bindActionCreators } from 'redux';
import actions from './actions';

export default function mapDispatchToProps(dispatch) {
  const creators = Map()
    .merge(...actions)
    .filter(value => typeof value === 'function')
    .toObject();

  return {
    actions: bindActionCreators(creators, dispatch),
    dispatch
  };
}

Jak widzisz, jest tutaj kilka importów. Z pakietu redux pobieram funkcję bindActionCreators, o której za chwilę. Zwróć też uwagę, że importuję actions. Jest to stworzona przez nas wcześniej tablica grupująca wszystkie funkcje kreatorów akcji.

Przejdźmy do implementacji funkcji mapDispatchToProps. Jak widzisz, najpierw tworzona jest struktura danych Map z Immutable.js. Na jej rzecz wywoływana jest funkcja merge, która łączy wszystkie funkcje z tablicy actions i umieszcza je w obiekcie Map. Następnie filtrujemy wszystkie właściwości zapisane w obiekcie Map tak aby pozostały w nim tylko funkcje. Na koniec transformujemy obiekt Map na czysty obiekt JavaScript.

Czas teraz przejść do obiektu zwracanego przez funkcję mapDispatchToProps. Jak widzisz, zawiera on właściwość actions, której przypisywana jest wartość zwracana przez wywołanie funkcji bindActionCreators. O funkcji tej możesz więcej przeczytać w dokumentacji. Ja w skrócie napiszę, że przyjmuje ona jako parametry: obiekt zawierający wszystkie funkcje kreatorów akcji oraz funkcję dispatch. Zwraca ona natomiast obiekt, zawierający właściwości odpowiadające nazwom kreatorów akcji, do których przypisane są funkcje wywołujące funkcję dispatch.

Aby lepiej sobie zobrazować jak w rezultacie wygląda obiekt przypisany do właściwości actions przedstawiam poglądowy przykład:

{
  increment: () => dispatch(actions.increment()),
  decrement: () => dispatch(actions.decrement()),
  setName: () => dispatch(actions.setName()),
  setAge: () => dispatch(actions.setAge())
}

Obiekt zwracany przez funkcję matDispatchToProps przypisywany jest później za pomocą funkcji connect do “propsów” dostępnych w komponentach ReactJS. Jak się więc domyślasz, poszczególne akcje dostępne są w komponencie za pomocą this.props.actions.nazwaAkcji.

Funkcja connect jako dekorator

Skoro już mówimy o funkcji connect to istnieje wygodniejszy sposób jej wywołania. Ostatnio robiliśmy to tak:

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

Dla przypomnienia: Counter w powyższym przykładzie jest komponentem ReactJS. Warto jednak wiedzieć, że funkcja connect może też zostać użyta jako dekorator. Obczaj poniższy przykład:

@connect(mapStateToProps, mapDispatchToProps)
class Counter extends React.Component {
 ...
}

Zwróć uwagę na znak @ stojący przed connect. To właśnie mówi kompilatorowi, że ma do czynienia z dekoratorem. Generalnie dekoratory to funkcje, które biorą to co znajduje się bezpośrednio pod nimi i owijają to w funkcję dekorującą. Czyli dzieje się tutaj dokładnie to samo co w przykładzie bez dekoratora: funkcja connect wykonuje się, a jej rezultat to kolejna funkcja, która bierze komponent ReactJS jako parametr. Jest to po prostu inny zapis tego samego.

Dekoratory należą do specyfikacji ES7 i w chwili pisania tego artykułu wymagają użycia odpowiedniej wtyczki do Babela. Możesz poczytać na ten temat na przykład tutaj.

Usprawnienia Redux - podsumowanie

To wszystkie usprawnienia Redux jakie na dziś przygotowałem. Mam nadzieję, że będą one dla Ciebie przydatne. Jednocześnie zachęcam do dzielenia się spostrzeżeniami w komentarzach - jeśli coś pominąłem albo uważasz, że robię to źle, daj znać - chętnie podyskutuję.