Spis Treści
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:
- Użytkownik klika przycisk countera
- Wykonywane jest zapytanie na serwer
- Aplikacja pyta użytkownika czy chce załadować nowe dane
- 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ć.