Dowiedz się, jak obsługiwać efekty uboczne dzięki Redux Saga

Spis Treści

  1. Wprowadzenie
  2. Generatory
    1. Przykład generatora
    2. Kod przykładu generatora na Github
  3. Redux Saga
    1. Przykład Sagi
    2. Pełny kod Sagi
    3. Kod przykładu Sagi na Github
  4. Podsumowanie

Wprowadzenie

Redux Saga to popularny middleware dla Reduxa, oparty na generatorach. Umożliwia obsługę asynchroniczności w sposób przypominający kod synchroniczny.

Warto poznać tą bibliotekę ze względu na prostotę tworzonego przy jej użyciu kodu – Sagi są dobrym miejscem na izolację logiki naszej aplikacji, oddzielenie jej od UI i od samego Reduxa.

Można pokusić się o pytanie, czemu zamiast Redux Saga nie użyć Redux Thunk wraz ze składnią async / await? Przewagą Sag jest możliwość tworzenia bardziej skomplikowanego flow obejmujące chociażby oczekiwanie na pojawienie się konkretnej akcji.

Generatory

Generatory, które są używane do tworzenia Sag, są funkcjami które uruchamiają się do pewnego momentu po czym „zamarzają”, a od momentu zatrzymania można je uruchomić dalej.

Przykład generatora

Spójrzmy na przykład generatora:

function* createGenerator() {
  let variable = 1;
  yield variable;
  variable+=1;
  yield variable;
  variable = `${variable} as string`;
  yield variable;
  variable = `${variable} but with done: true`;
  return variable; //return ends generator
  yield 'something'
}

Wywołanie takiej funkcji spowoduje utworzenie obiektu typu Generator:

const generator = createGenerator();

Generator taki posiada metodę next() która zwróci obiekt z 2 polami – polem value zawierającą wartość wywołaną przez yield oraz polem done, oznaczającą czy generator się zakończył. Kolejne wywołania metody next na zakończonym generatorze będą skutkować zwróceniem obiektu z value ustawionym na undefined i done ustawionym na true.
Nie dojdziemy więc do ostatniej linijki z yield something.

Sprawdźmy działanie takiego generatora poprzez wypisywanie rezultatów tego wywołań, mając taki kod HTML:

<button id="generate">
  Generate
</button>
<p>
  Result: <span id="result"></span>
</p>

Podepnijmy kod JS powodujący dopisywanie rezultatu wywołania next():

const generator = createGenerator();

document.querySelector('#generate').addEventListener('click', () => {
  document.querySelector('#result').innerHTML +=  `<pre>${JSON.stringify(generator.next())}</pre>`;
});

Kilkukrotne kliknięcie przycisku „Generate” wyświetli następujące rezultaty:

Kod przykładu generatora na Github

Pełny kod przykładu znajdziesz w repozytorium Github pod adresem:

https://github.com/radek-anuszewski/generators-example

Redux Saga

Przykład Sagi

Utwórzmy aplikację CRA, która zawierać będzie Reacta, Reduxa i Redux Sagę. Najszybciej będzie skorzystać z gotowego szablonu do create-react-app:

npx create-react-app my-app --template redux

Jak możemy zauważyć w kodzie wygenerowanej aplikacji, używa ona biblioteki Redux Toolkit, co widać m.in. w kodzie pliku counterSlice:

import { createSlice } from '@reduxjs/toolkit';

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
  },
  reducers: {
    increment: state => {
      // Redux Toolkit allows us to write "mutating" logic in reducers. It
      // doesn't actually mutate the state because it uses the Immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes
      state.value += 1;
    },
    // inne reducery
  },
});

// reszta kodu

Użycie tej biblioteki uprości nam znacznie kod i zredukuje charakterystyczny dla Reduxa boilerplate. Nie będę w poście omawiał tej biblioteki szczegółowo, jeżeli chcesz dowiedzieć się o niej więcej możesz zapisać się na moją listę mailową i otrzymać dokument opisujący bibliotekę:

Zainstalujmy również bibliotekę Redux Saga:

npm install --save redux-saga

Przykładowy kod zawiera aplikację Countera, dodajmy do niej asynchroniczność pod postacią zapisywania historii, symulując wysyłanie zapytań na serwer. Utwórzmy plik onCounterChangedSaga który zawierać będzie kod naszej Sagi.

Ponieważ będziemy chcieli reagować na akcje zmieniające wartość countera, musimy zaimportować metodę takeEvery, która jako pierwszy parametr przyjmuje akcje na jakie ma reagować a drugim parametrem jest funkcja, jaka ma być wywoływana:

export function* onCounterChangedSaga() {
  yield takeEvery([
    increment.toString(),
    decrement.toString(),
    incrementByAmount.toString(),
  ], onCounterChanged)
}

Funkcję onCounterChanged napiszemy za chwilę, skupmy się w tym momencie na pierwszym parametrze, liście akcji na jakie chcemy się zapiąć. Klasyczny kod Reduxa wyglądałby tak:

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

export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });

export function* onCounterChangedSaga() {
  yield takeEvery([INCREMENT, DECREMENT], onCounterChanged)
}

Gdzie typy akcji zadeklarowane są explicit, np jako stałe. Natomiast jeżeli używamy Redux Toolkit, akcje (jak już wiesz z dokumentu 🙂 ) typ akcji jest tworzony przez bibliotekę, dostęp do niego mamy poprzez action.toString() i nie musimy sami tego deklarować – to kolejna korzyść ze stosowania Redux Toolkit.

Gdy mamy już przechwyconą akcję, chcemy „zapisać” ją na serwerze. Napiszmy więc kod symulujący żądanie na serwer, odpowiadający dotychczas zapisanymi akcjami:

const actions = [];

const addActionRequest = (action) => new Promise((resolve, reject) => {
  actions.push({
    ...action,
    id: Date.now(),
  });
  // Need to copy array because usage as resolve parameter makes it not extensible
  resolve([...actions])
})

Zapytanie to wywołamy za pomocą funkcji call, która jako pierwszy parametr przyjmuje asynchroniczną funkcję do wywołania a jako kolejne parametry to co ma być do funkcji przekazane:

function* onCounterChanged (action) {
  const response = yield call(addActionRequest, action);
}

Ponieważ flow jaki chcemy uzyskać, na potrzeby tego przykładu, jest następujący:

  1. Użytkownik klika przycisk countera
  2. Wykonywane jest zapytanie na serwer
  3. Aplikacja pyta użytkownika czy chce załadować nowe dane
  4. Dane są ładowane jeśli ten się zgodzi

Musimy dodać nowe akcje w pliku counterSlice i dodać je do reducerów:

setOperations: (state, action) => {
  // save and show history
  state.operations = action.payload;
},
showAgreement: (state, action) => {
  // show agreement modal
  state.agreementVisible = true;
},
applyHistoryDecision: (state, action) => {
  // hide agreement modal after decision
  state.agreementVisible = false;
},

Oraz dodać do eksportu. W pliku Counter.js pobieramy interesujące nas dane, czyli historię operacji oraz to, czy ma wyświetlać się zapytanie o wyświetlenie świeżo pobranej historii:

const { operations, agreementVisible } = useSelector(state => state.counter);

Oraz dodajemy kod odpowiedzialny za wyświetlanie:

{
  agreementVisible && (
    <dialog open>
      <p>
        Do you want to apply reloaded history?
      </p>
      <button onClick={() => dispatch(applyHistoryDecision(true))}>
        Yes
      </button>
      <button onClick={() => dispatch(applyHistoryDecision(false))}>
        No
      </button>
    </dialog>
  )
}
<div>
  Operations:
    {
      operations.map(({type, payload, id}) => (
        <p key={id}>
          Type: {type}<br/>
          Payload: {payload}<br/>
        </p>
      ))
    }
</div>

Następnie, wracając do sagi, dodajemy kod odpowiedzialny za zdispatchowanie akcji pokazującej zapytanie za pomocą metody put, która wrzuca akcję do Reduxa:

yield put(showAgreement());

Jeśli chcemy poczekać na pojedynczą akcję, możemy użyć metody take. Metoda ta jako parametr przyjmuje typ akcji a zwraca tą akcję – wraz z payload, które będzie nam potrzebne:

const { payload: decision } = yield take(applyHistoryDecision.toString());

Co ważne, to czekanie nie blokuje głównego wątku – użytkownik nadal może klikać po aplikacji.

Pozostało tylko wrzucić do stanu historię, jeżeli użytkownik tego chce:

if (decision) {
  yield delay(3000);
  yield put(setOperations(response));
}

Dodaliśmy dodatkowo delay, również udostępnioną przez Redux, aby zasymulować jakieś procesowanie decyzji użytkownika.

Pełny kod Sagi

Oto pełny kod Sagi:

function* onCounterChanged (action) {
  const response = yield call(addActionRequest, action);
  yield put(showAgreement());
  const { payload: decision } = yield take(applyHistoryDecision.toString());
  if (decision) {
    yield delay(3000);
    yield put(setOperations(response));
  }
}

Kod przykładu Sagi na Github

Pełny kod do pobrania i uruchomienia na komputerze znajdziesz tutaj:

https://github.com/radek-anuszewski/redux-saga-example

Podsumowanie

Lektura tego postu da Ci podstawy do dalszego zagłębiania się w bibliotekę Redux Saga. Jej sposób obsługi kodu asynchronicznego oraz możliwość zamknięcia logiki biznesowej i oddzielenie jej od kodu komponentów oraz kodu Reduxa powodują, że dobrze ją znać.