Dziś miał być wpis na temat RxJS w połączeniu z Redux. Jednak kiedy rozpocząłem pracę nad nim zauważyłem, że to co powinienem najpierw omówić to wywołania asynchroniczne Redux. Dlatego też zdecydowałem, że dziś przedstawię 2 sposoby na takie wywołania, a wpis o RxJS w Redux, pojawi się następnej kolejności (czyli prawdopodobnie w czwartek).

Zanim zaczniemy

Na potrzeby omówienia jak działają wywołania asynchroniczne Redux zakładam, że kwestie takie jak podstawy React czy Redux masz już opanowane. Jeśli tak nie jest, to zalecam najpierw zapoznać się z moimi wcześniejszymi wpisami, w których znajdziesz stosowną wiedzę z tego zakresu:

Przeczytanie wpisu na temat usprawnień Redux może nie jest absolutnie konieczne do zrozumienia treści dzisiejszego wpisu ale myślę, że mimo wszystko warto się z nim zapoznać. Ważne natomiast, abyś koniecznie znał zagadnienie middleware ponieważ, będzie to podstawą obu prezentowanych dziś rozwiązań!

Ok, skoro jesteś już przygotowany na to co dziś pokażę, przejdźmy teraz do pierwszego sposobu na wywołania asynchroniczne Redux!

Sposób pierwszy - redux-thunk

Najbardziej podstawowym sposobem realizacji wywołań asynchronicznych jest wykorzystanie specjalnego middleware o nazwieredux-thunk. Dotychczas nie omówiłem jeszcze tego middleware na blogu (było w planach)… Dlatego zanim pokażę jak wykorzystać go do wywołań asynchronicznych, zrobię małą dygresję i przedstawię Ci o co chodzi.

Dygresja: co to jest redux-thunk

Biblioteka redux-thunk, stworzona została przez Dana Abramova, który jednocześnie jest twórcą Reduxa. Pozwala ona tworzyć kreatory akcji, które zamiast obiektu zwracają funkcję. Dzięki temu możliwe jest opóźnienie rozgłoszenia (ang. “dispatch) akcji lub rozgłoszenie jej tylko jeśli zostaną spełnione określone warunki.

Najważniejsze w tym wszystkim jest to, że funkcja taka przyjmuje dwa parametry: dispatch oraz getState. Zanim jednak pokażę jak tego użyć w kreatorze akcji, rzućmy okiem na konfigurację tego middleware:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers/index';

const store = createStore(
  rootReducer,
  applyMiddleware(thunk)
);

Jak widzisz, redux-thunk to zwykły middleware więc po prostu przekazujemy go jako parametr funkcji applyMiddleware tak jak to robimy z każdym innym middleware.

Spójrz zatem teraz na przykład kreatora akcji wykorzystującego redux-thunk:

export const GET_DATA_ONE = 'GET_DATA_ONE';
export const GET_DATA_TWO = 'GET_DATA_TWO';

export function getDataOne() {
  return {
    type: 'GET_DATA_ONE'
  };
}

export function getDataTwo() {
  return {
    type: 'GET_DATA_TWO'
  };
}

export function getData(parameter) {
  return (dispatch) => {
    if (parameter) {
      dispatch(getDataOne());
    } else {
      dispatch(getDataTwo());
    }
  };
}

Powyższy kod nie jest specjalnie wyszukany. Najważniejsze jest to, że funkcja getData zwraca funkcję przyjmującą parametr dispatch, który umożliwia nam rozgłoszenie innych akcji w zależności od parametru parameter. Za chwilę wykorzystamy tę właściwość redux-thunk przy wywołaniach asynchronicznych. Pewnie nawet domyślasz się już na jakiej zasadzie to będzie działać…

Obsługa wywołań asynchronicznych - kreatory akcji

Ok, wiemy już co to jest redux-thunk i jak działa. Wykorzystajmy go teraz do obsługi wywołań asynchronicznych. Na początek spójrz na przykładowe kreatory akcji:

import fetch from 'isomorphic-fetch';

export const GET_DATA_REQUESTED = 'GET_DATA_REQUESTED';
export const GET_DATA_DONE = 'GET_DATA_DONE';
export const GET_DATA_FAILED = 'GET_DATA_FAILED';

export function getDataRequested() {
  return {
    type: 'GET_DATA_REQUESTED'
  };
}

export function getDataDone(data) {
  return {
    type: 'GET_DATA_DONE',
    payload: data
  };
}

export function getDataFailed(error) {
  return {
    type: 'GET_DATA_FAILED',
    payload: error
  };
}

export function getData() {
  return dispatch => {
    // set state to "loading"
    dispatch(getDataRequested());

    fetch('https://api.github.com/users/burczu/repos')
      .then(response => response.json())
      .then(data => {
        // set state for success
        dispatch(getDataDone(data));
      })
      .catch(error => {
        // set state for error
        dispatch(getDataFailed(error));
      })
  }
}

Omówienie kodu

Na początku importuję funkcję fetch należącą do biblioteki isomorphic-fetch, która posłuży nam do wywołań AJAX. Oczywiście możesz użyć dowolnej innej biblioteki - to jest tylko przykład.

Następna rzecz to eksport stałych zawierających nazwy typów akcji. Zwróć uwagę, że mamy tutaj trzy typy: GET_DATA_REQUESTED będzie wywołany na początku operacji pobierania danych. Przestawimy wtedy “store” w stan ładowania danych. Kolejny typ to GET_DATA_DONE. Akcja tego typu spowoduje zakończenie stanu ładowania i zapisanie danych w store. Ostatni typ to GET_DATA_FAILED, który wykorzystamy w akcji wywoływanej w przypadku niepowodzenia operacji pobierania danych.

Dalej mamy trzy kreatory akcji, odpowiedzialne za utworzenie akcji opisanych przed chwilą typów. Myślę, że nie trzeba ich dodatkowo wyjaśniać ponieważ, wszystko co trzeba znajdziesz w moich poprzednich wpisach o Redux.

To co najciekawsze w powyższym przykładzie to implementacja funkcji getData. Jak widzisz, zwraca ona funkcję przyjmującą parametr dispatch (można tutaj przyjąć również dodatkowo parametr getState ale nie jest nam to w tym momencie potrzebne). W pierwszej linii tej funkcji rozgłaszamy akcję GET_DATA_REQUESTED (poprzez wywołanie funkcji getDataRequested()). Możemy w ten sposób obsłużyć rozpoczęcie ładowania danych i przedstawić to na ekranie na przykład, wyświetlając animowany “loader”.

Dalej następuje wywołanie funkcji fetch, która “strzela” do publicznego API serwisu GitHub i pobiera listą moich publicznych repozytoriów. To co najważniejsze, dzieje się w przypadku pomyślnego odebrania danych (drugi then) oraz w przypadku błędu (callback przekazany do catch). W pierwszym przypadku rozgłaszamy akcję GET_DATA_DONE, a w drugim GET_DATA_FAILED. Dzięki temu będziemy mogli odpowiednio zmodyfikować stan w “reducerze” w zależności od wyniku wywołania asynchronicznego.

Obsługa w reducerze

Jeśli dobrze rozumiesz już zasadę działania Reduxa (na przykład po przeczytaniu poprzednich moich wpisów na ten temat), to pewnie w tym momencie rozumiesz już jak to wszystko działa. Dla porządku jednak przedstawię Ci resztę kodu, który odpowiedzialny jest za wywołania asynchroniczne Redux. Najpierw kod “reducera”:

import * as actions from './actions';

export const reducer = (state, action) => {
  switch (action.type) {
    case actions.GET_DATA_REQUESTED:
      return { ...state, isLoading: true };
    case actions.GET_DATA_DONE:
      return { ...state, isLoading: false, repositories: action.payload };
    case actions.GET_DATA_FAILED:
      return { ...state, isLoading: false, isError: true }
    default:
      return state;
  }
};

Dla akcji GET_DATA_REQUESTED zwracam stan, w którym zmieniam tylko wartość właściwości isLoading na true.

Z kolei obsługując akcję GET_DATA_DONE przywracam stan początkowy właściwości isLoading (ustawiam wartość false) oraz przypisuję dane pobrane z API do właściwości repositories.

W przypadku gdy pobieranie danych zakończy się rozgłoszeniem akcji GET_DATA_FAILED, wartość właściwości isLoading również jest ustawiana z powrotem na false. Dodatkowo zmieniam wartość właściwości isError na true tak aby móc odpowiednio odzwierciedlić tę sytuację na ekranie.

Pozostały kod: konfiguracja “store”, wiązanie z this.props, komponent React

Na koniec przedstawiam pozostały kod, w którym konfiguruję “store” Reduxa, wiążę stan ze zmienną this.props komponentu oraz renderuję komponent na ekranie:

import React, { PropTypes } from 'react';
import { render } from 'react-dom';
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { connect, Provider } from 'react-redux';
import * as actions from './actions';
import { reducer } from './reducer';

const store = createStore(
  reducer,
  { isLoading: false, isError: false, repositories: [] },
  applyMiddleware(thunk)
);

class Repositories extends React.Component {
  componentDidMount() {
    const { getData } = this.props;
    getData();
  }

  render() {
    const { isLoading, isError, repositories } = this.props;

    // TODO: add isLoading and isError handling
    return (
      <div>
        {repositories.map((item, index) => {
          return (<div key={index}>
            {item.name}
          </div>);
        })}
      </div>
    );
  }
}

const mapStateToProps = (state) => {
  return state;
};
const mapDispatchToProps = (dispatch) => {
  return {
    getData: () => dispatch(actions.getData())
  }
};

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

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

Myślę, że nie ma sensu dokładnie tutaj wszystkiego omawiać - jest to kod podobny do tego jaki przedstawiłem w moim wpisie na temat podstaw Reduxa. Zwróć jedynie uwagę, na wywołanie metody createStore gdzie przekazuję wywołanie funkcji applyMiddleware z parametrem thunk.

Kolejna rzecz warta odnotowania to komponent React. W metodzie render odnoszę się do właściwości repositories stanu Reduxa wyświetlając nazwy poszczególnych repozytoriów na ekranie. Na potrzeby przykładu pominąłem obsługę właściwości isLoading oraz isError. Jeśli chcesz, możesz dodać odpowiedni kod w ramach zadania domowego.

Kod przedstawionego przykładu dostępny jest na GitHubie - możesz go sobie sklonować i przeanalizować we własnym zakresie.

Sposób drugi - redux-promise-middleware

Oprócz redux-thunk istnieje jeszcze inne przydatne middleware o nazwie redux-promise-middleware. Spójrz najpierw na jego konfigurację:

import promiseMiddleware from 'redux-promise-middleware';

...

const store = createStore(
  reducer,
  { isLoading: false, isError: false, repositories: [] },
  applyMiddleware(promiseMiddleware())
);

W pierwszej linii następuje import funkcji promiseMiddleware z biblioteki redux-promise-middleware. Oczywiście jest ona dostępna w npm i instaluje się ją w sposób najzupełniej standardowy (npm install --save-dev ...).

W dalszej części przykładu, do metody applyMiddleware przekazujemy wywołanie funkcji promiseMiddleware() - możemy tutaj przekazać dodatkowe parametry ale o tym za chwilę.

Kreatory akcji

Przejdźmy teraz do pliku zawierającego kreatory akcji z przykładu dla redux-thunk i zmodyfikujmy go trochę:

import fetch from 'isomorphic-fetch';

export const GET_DATA_PENDING = 'GET_DATA_PENDING';
export const GET_DATA_FULFILLED = 'GET_DATA_FULFILLED';
export const GET_DATA_REJECTED = 'GET_DATA_REJECTED';

export function getData() {
  return {
    type: 'GET_DATA',
    payload: {
      promise: fetch('https://api.github.com/users/burczu/repos')
        .then(response => response.json())
    }
  };
}

Jak widzisz, zmieniły się co nieco nazwy typów akcji. Do tego zniknęły kreatory akcji korzystające z tych typów… To dlatego, że redux-promise-middleware dodaje obsługuje to wszystko we własnym zakresie.

Pozostał nam jedynie kreator akcji o nazwie getData. Jednak trochę się nam on uprościł: tym razem nie zwracamy funkcji, a obiekt akcji z typem ustawionym na wartość GET_DATA. Oprócz tego przekazujemy jako właściwość payload obiekt, który zawiera właściwość promise, do której przypisane zostało wywołanie funkcji fetch.

Zmiany w reducerze

Jak wspomniałem, opisywany middleware obsługuje poszczególne kreatory akcji we własnym zakresie. Działa to w ten sposób, że dla typu GET_DATA, doklejany jest sufiks, dla poszczególnych akcji. A więc, rozgłaszając akcję GET_DATA, tak na prawdę, rozgłaszana jest akcja GET_DATA_PENDING na starcie operacji, potem dla operacji zakończonej sukcesem rozgłaszana jest akcja GET_DATA_FULFILLED, a dla przypadku z błędem rozgłasza się akcja GET_DATA_REJECTED.

Z tego też względu musimy zmodyfikować też nasz reducer, tak by obsługiwał nowe typy akcji:

import * as actions from './actions';

export const reducer = (state, action) => {
  switch (action.type) {
    case actions.GET_DATA_PENDING:
      return { ...state, isLoading: true };
    case actions.GET_DATA_FULFILLED:
      return { ...state, isLoading: false, repositories: action.payload };
    case actions.GET_DATA_REJECTED:
      return { ...state, isLoading: false, isError: true }
    default:
      return state;
  }
};

Zmiana domyślnych sufiksów

Na szczęście nie jesteśmy ograniczeni do wspomnianych sufiksów _PENDING, _FULFILLED oraz _REJECTED. Możemy skonfigurować własne sufiksy przy tworzeniu “store” Reduxa:

const store = createStore(
  reducer,
  { isLoading: false, isError: false, repositories: [] },
  applyMiddleware(promiseMiddleware({
    promiseTypeSuffixes: ['REQUESTED', 'DONE', 'FAILED']
  }))
);

Jak widzisz, wystarczy podczas wywołania funkcji promiseMiddleware() przekazać do niego obiekt, jako parametr wywołania, który zawiera właściwość promiseTypeSuffixes. Do właściwości tej przypisać wystarczy tablicę zawierającą sufiksy dla poszczególnych typów akcji. W powyższym przykładzie ustawiłem sufiksy, które odpowiadają tym z przykładu dla redux-thunk.

Wywołania asynchroniczne Redux - podsumowanie

Jak widzisz, wywołania asynchroniczne Redux to nic trudnego. Pierwsze z przedstawionych podejść jest najbardziej podstawowym i warto dobrze je poznać. Drugie stosowałem osobiście w jednym z projektów i sprawdza się całkiem dobrze. Są jednak sytuacje kiedy i tak przydaje się redux-thunk. Dlatego też znajomość tego middleware jest bardzo ważna przy pracy z Reduxem.

Dzięki temu wpisowi, w kolejnym wpisie będę mógł pójść krok dalej i omówić wykorzystanie RxJS wraz z Redux. Myślę, że dzięki temu temat Redux będzie jednym z lepiej opisanych na tym blogu!