Redux-Saga – obsługa asynchronicznych akcji w Redux

Adrian Gut

Redux-Saga to popularna biblioteka, której celem jest ułatwienie zarządzania efektami ubocznymi w aplikacji Redux. Te odnoszą się do kodu wykonującego operacje, które mogą potencjalnie modyfikować stan aplikacji, np. wykonywać wywołania API lub wchodzić w interakcję z bazą danych. W aplikacji Redux ważne jest, aby zarządzanie efektami ubocznymi oddzielić od zarządzania stanem aplikacji. Dlaczego? Może to ułatwić pisanie, testowanie i utrzymywanie kodu. Z tekstu dowiesz się, jak zainstalować i wykorzystać Redux-Saga.

Wprowadzenie

Redux-Saga jest jednym ze sposobów zarządzania efektami ubocznymi w aplikacji Redux. Do ich obsługi biblioteka ta wykorzystuje koncepcję zwaną sagą. Jest to funkcja generatora, którą można używać do wykonywania zadań asynchronicznych, np. wywoływania API, w sposób deklaratywny.

Instalacja

Redux-Saga jest dostępne poprzez NPM. Możesz go zainstalować za pomocą następującego polecenia:

npm i redux-saga

Wykorzystanie

Następnym krokiem jest utworzenie sagi, która będzie obsługiwać efekt uboczny. Jak to zrobić? Prosty przykład sagi może wyglądać następująco:

import { put, takeEvery, all } from 'redux-saga/effects';

export function* fetchData(action) {
  try {
    const data = yield call(fetch, action.payload.url);
    yield put({ type: 'FETCH_SUCCEEDED', data });
  } catch (e) {
    yield put({ type: 'FETCH_FAILED', message: e.message });
  }
}

function* watchFetchData() {
  yield takeEvery('FETCH_REQUESTED', fetchData);
}

export default function* rootSaga() {
  yield all([watchFetchData()]);
}

W tym przykładzie saga fetchData jest odpowiedzialna za wykonanie wywołania API po otrzymaniu akcji FETCH_REQUESTED. Jeśli wywołanie API powiedzie się, wyśle ona akcję FETCH_SUCCEEDED z danymi zwróconymi z API. Gdy się to nie uda, wysłana zostanie akcja FETCH_FAILED z komunikatem o błędzie.

Saga watchFetchData jest odpowiedzialna za obserwowanie akcji FETCH_REQUESTED i wyzwalanie sagi fetchData po ich otrzymaniu. Funkcja rootSaga łączy natomiast wszystkie sagi w aplikacji w jeden generator, który może być uruchamiany przez middleware.

Aby móc skorzystać z sag w aplikacji Redux, należy dodać Redux-Saga middleware podczas tworzenia Redux store:

import createSagaMiddleware from 'redux-saga';
import rootSaga from './sagas';

const sagaMiddleware = createSagaMiddleware();

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

sagaMiddleware.run(rootSaga);

Dzięki zastosowaniu Redux-Saga middleware jesteś w stanie „nasłuchiwać” akcji wysyłanych do Redux store i obsługiwać efekty uboczne.

Testy

Główne założenia

Efekty asynchroniczne są zwykle trudne do prawidłowego modelowania i testowania. Dlaczego? Ze względu na zewnętrzne zależności, które mogą doprowadzić do niepowodzenia testu, nawet jeśli proces został wykonany poprawnie. Na szczęście Redux-Saga pozwala nam korzystać z funkcji generatora i słowa kluczowego yield. Dodatkowo call i put tworzą zwykłe obiekty opisujące akcję. Do napisania prawidłowego testu wystarczy więc, że sprawdzisz, czy oczekiwane i otrzymane wartości są równe.

Aby dokładniej wyjaśnić ten proces, stwórzmy prostą sagę, która – po niewielkim opóźnieniu – zwiększa lub zmniejsza wartość na podstawie danych wprowadzonych przez użytkownika. Jak to zrobić? Spójrz:

const requestValue = (value) => ({
  type: 'REQUEST_VALUE',
  payload: { value },
});

const delay = (ms) => new Promise((res) => setTimeout(res, ms));

function* incrementOrDecrementAfterDelay() {
  const action = yield take('REQUEST_VALUE');
  yield delay(1000);
  if (action.payload.value > 0) {
    yield put({ type: 'INCREMENT' });
  } else {
    yield put({ type: 'DECREMENT' });
  }
}

Testowanie funkcji generatora jest prostym procesem:

import test from 'tape';
import { call, put, take } from 'redux-saga/effects';
import { delay, incrementOrDecrementAfterDelay } from './sagas';

test('incrementOrDecrementAfterDelay test', (assert) => {
  const gen = incrementOrDecrementAfterDelay();

  assert.deepEqual(
    gen.next().value,
    take('REQUEST_VALUE'),
    'it should take user input'
  );

  assert.deepEqual(
    gen.next().value,
    call(delay, 1000),
    'it should call delay(1000)'
  );

  const positiveUserInput = 2;

  assert.deepEqual(
    gen.next(requestValue(positiveUserInput)).value,
    put({ type: 'INCREMENT' }),
    'it should increment after a positive value'
  );

  assert.deepEqual(gen.next().done, true, 'it should be done');
});

Kod ma jednak rozgałęzienia, a my przetestowaliśmy tylko jedną gałąź. Redux-Saga udostępnia funkcję użytkową cloneableGenerator, która pomaga testować kod bez zbędnych powtórzeń. Zaktualizowany kod wygląda następująco:

import test from 'tape';
import { call, put, take } from 'redux-saga/effects';
import { cloneableGenerator } from '@redux-saga/testing-utils';
import { delay, incrementOrDecrementAfterDelay } from './sagas';

test('incrementOrDecrementAfterDelay test', (assert) => {
  const gen = cloneableGenerator(incrementOrDecrementAfterDelay)();

  assert.deepEqual(
    gen.next().value,
    take('REQUEST_VALUE'),
    'it should take user input'
  );

  assert.deepEqual(
    gen.next().value,
    call(delay, 1000),
    'it should call delay(1000)'
  );

  assert.test('user input a positive number', (a) => {
    const cloned = gen.clone();
    const positiveUserInput = 2;

    a.deepEqual(
      cloned.next(requestValue(positiveUserInput)).value,
      put({ type: 'INCREMENT' }),
      'it should increment after a positive value'
    );

    a.deepEqual(cloned.next().done, true, 'it should be done');

    a.end();
  });

  assert.test('user input a nonpositive number', (a) => {
    const cloned = gen.clone();
    const nonpositiveUserInput = -2;

    a.deepEqual(
      cloned.next(requestValue(nonpositiveUserInput)).value,
      put({ type: 'DECREMENT' }),
      'it should decrement after a nonpositive value'
    );

    a.deepEqual(cloned.next().done, true, 'it should be done');

    a.end();
  });
});

Obydwa rozgałęzienia funkcji incrementOrDecrementAfterDelay zostały poprawnie przetestowane.

Obsługa błędów

Czasami obsługi błędów wymagają funkcje. Dlaczego? Z powodu nieprawidłowych wartości, nieudanych żądań, niepożądanych stanów lub danych wejściowych itp. Dla przypomnienia – załóżmy, że „0” jest stanem niepożądanym i powinno zakomunikować poprawnie błąd:

function* incrementOrDecrementAfterDelay() {
  try {
    const action = yield take('REQUEST_VALUE');
    yield delay(1000);
    if (action.payload.value > 0) {
      yield put({ type: 'INCREMENT' });
    } else if (action.payload.value < 0) {
      yield put({ type: 'DECREMENT' });
    } else {
      throw new Error('0 cannot cause incrementation or decrementation!');
    }
  } catch (e) {
    yield put({ type: 'INCREMENT_OR_DECREMENT_AFTER_DELAY_FAILED', error });
  }
}

Obsługa tego przypadku w naszym teście jest kwestią użycia metody generatora .throw(). Aktualizacja testu o nowy przypadek wygląda następująco:

// imports
// ...

test('incrementOrDecrementAfterDelay test', (assert) => {
  const gen = cloneableGenerator(incrementOrDecrementAfterDelay)();

  // rest of the function body
  // ...

  assert.test('user input an incorrect value', (a) => {
    const cloned = gen.clone();
    const fakeError = {};

    a.deepEqual(
      cloned.throw(fakeError).value,
      put({ type: 'INCREMENT_OR_DECREMENT_AFTER_DELAY_FAILED', error }),
      'it should catch a thrown error after receiving an incorrect value'
    );

    a.end();
  });
});

Narzędzia

Fork

Fork tworzy kod działający w tle „przyłączony” pod root. Saga nie zakończy się, dopóki wszystkie dołączone forki nie zakończą wykonywania. Stanie się tak nawet wtedy, gdy root sagi już przestał działać. Sagi przerywają działanie, jeśli główna część „rzuci” błąd lub w jakimkolwiek forku zostanie wykryty błąd nieprzechwycony. Ponieważ forki tworzą się w tle, główna część może już zakończyć wykonywanie własnego kodu. Z tego względu sagi nie są w stanie wychwycić błędów z zadań forków. Przerwana saga anuluje działanie własne oraz wszystkich forków, a następnie zwraca błąd.

Spawn

Spawn tworzy kod odłączony od roota. Odłączone forki nie współdzielą kontekstu wykonania z nadrzędną sagą. Oznacza to, że root nie czeka na ich zakończenie. Wszelkie nieprzechwycone błędy nie są też do niego przekazywane. Kontekst wykonania jest inny dla każdego odłączonego rozwidlenia. Oznacza to, że:

  • root nie anuluje forków utworzonych przez spawn, nawet jeśli zostaną przerwane;
  • jeden odłączony fork może przerwać działanie bez wpływu na inne forki.

Przydaje się to w przypadku zadań, w których niepowodzenie jednego z nich nie ma wpływu na inne. Oznacza to również, że musisz manualnie anulować forki utworzone przez spawn, jeżeli tego potrzebujesz.

„Promisopodobne dodatki

Redux-Saga zawiera kilka pomocnych funkcji podobnych do metod Promise. Są to:

  • all, która przypomina Promise.all() – wszystkie funkcje zostaną wykonane asynchronicznie, jednak główna będzie wznowiona dopiero po wykonaniu każdej z przekazanych funkcji;
  • race, która podobna jest do Promise.race() – pierwsze zadanie, które zostanie zakończone, zostaje zwrócone – nieważne, czy zostało zakończone jako sukces, czy błąd.

Wnioski

Redux-Saga to potężne narzędzie, które może ułatwić zarządzanie efektami ubocznymi w aplikacji Redux. Pozwala ono deklaratywnie określać zadania asynchroniczne, które muszą być wykonywane w odpowiedzi na wywołaną akcje. W efekcie ułatwia pisanie, testowanie i utrzymywanie kodu.

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami