Redux Saga

У разработчиков двойственные впечатления о redux-saga. Кто-то любит саги, кто-то считает их бесполезными. Автор придерживается мнения, что саги удобны: они позволяют отделить логику взаимодействия с REST API, задать последовательность событий и реагировать на них.

Предлагаю пробежаться по настройке саг в связке с redux-toolkit, понять в какой момент они начинают работать и решить для себя нужны они вам или нет. Для любопытных ссылки на первоисточники в конце поста.

Примечание

Заметка будет полезна тем, кто уже знаком с основными понятиями redux. Если читатель никогда не пользовался этим менеджером состояния, многое может казаться странным из-за специфической терминологии.

Дабы не разводить кашу из switch-инструкций оригинального redux, воспользуемся вспомогательным средством в виде redux-toolkit. Для быстрого старта как всегда берём react-create-app.

Установка зависимостей:

$ npx create-react-app saga_app
$ cd saga_app
$ npm i axios redux react-redux @reduxjs/toolkit redux-saga redux-saga-routines

Redux Toolkit

В этом разделе приведён небольшой пример работы с redux-toolkit. Обычный счётчик, значение которого можно уменьшать или увеличивать. Если читатель захочет проверить нижеизложенное на практике, упомянутые файлы нужно создать самостоятельно.

Нам понадобятся стандартные actions и reducer. Когда вызывается action, на событие реагирует reducer — чистая функция, возвращающая новое состояние.

// src/store/counter.js
import { createAction, createReducer } from '@reduxjs/toolkit'

// action creators
export const increment = createAction('counter/INCREMENT')
export const decrement = createAction('counter/DECREMENT')

// reducer принимает начальное состояние 0
const counter = createReducer(0, {
  [increment.type]: state => state + 1,
  [decrement.type]: state => state - 1
})

export default counter

В главном файле хранилища подключаются редьюсеры и конфигурируется само хранилище.

// src/store/index.js
import { combineReducers } from 'redux'
import { configureStore } from '@reduxjs/toolkit'
import counter from './counter'

// можно передать несколько разных редьюсеров, у нас он пока один
const rootReducer = combineReducers({ counter })

export default configureStore({
  reducer: rootReducer,
})

Осталось подключить Provider и передать ему store.

// src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'

import store from './store'
import App from './app'

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
)

Переходим к входной точке приложения. Здесь выводим начальное значение счётчика и управляем им при помощи хука useDispatch.

// src/app.js
import { useDispatch, useSelector } from 'react-redux'
import { increment, decrement } from './store/counter'

function App() {
  const dispatch = useDispatch()
  // получить значение счётчика
  const counter = useSelector(state => state.counter)

  // вызывать действие (action) при нажатии на кнопку
  const handleCounterIncrement = () => dispatch(increment())
  const handleCounterDecrement = () => dispatch(decrement())

  return (
    <div>
      <p>{counter}</p>
      <button type="button" onClick={handleCounterIncrement}>+ counter</button>
      <button type="button" onClick={handleCounterDecrement}>- counter</button>
    </div>
  );
}

export default App

Redux Saga

Пример: есть авторизация. Отправляем логин и пароль, в ответ получаем токен. С этим токеном на руках идём за данными пользователя. Приступим.

API

Подойдёт любой доступный на просторах интернета открытый API. Ну и что-нибудь, что будет к этому API обращаться, будь то axios или fetch, не столь важно.

// src/api/instance.js
import axios from 'axios'

// экземпляр axios
export const apiInstance = (params = {}) => {
  const { token } = params
  const config = {
    baseURL: 'https://reqres.in/api',
    headers: { 'Content-Type': 'application/json' },
    timeout: 1000,
  };

  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return axios.create(config)
}

Описание методов API для работы с данными пользователя.

// src/api/user.js
import { apiInstance } from './instance'

export const User = {
  login: function (params = {}) {
    const { email, password } = params
    const api = apiInstance()

    return api.post('/login', { email, password })
  },

  getInfo: function (params = {}) {
    const { token } = params
    const api = apiInstance({ token })

    return api.get('/users/1')
  }
}

Хранилище

Чтобы было удобнее обращаться с сагами, создаём роутины. Из них можно извлечь action creators: success, fulfill, failure и использовать чтобы отдать данные дальше редьюсеру или же обработать ошибку.

// src/store/user.js
import { createReducer } from '@reduxjs/toolkit'
import { createRoutine } from 'redux-saga-routines'

// state
const initialState = {
  token: '',
  info: {},
}

// routine теперь заменяет action creator
export const login = createRoutine('user/LOG_IN')

// reducer откликается на созданные роутинами actions
const user = createReducer(initialState, {
  [login.SUCCESS]: (state, action) => {
    const { token, info } = action.payload
    return { ...state, token, info }
  }
})

export default user

Саги

Саги спокойно делятся на файлы и подключаются (подобно редьюсерам в rootReducer) в одной rootSaga. Саги — функции-генераторы. Их принято разделять на worker и watcher. Worker содержит логику. Watcher следит за экшенами.

Проще говоря, дело обстоит так: вы вызываете действие, а сага слушает когда какое-то действие будет вызвано и перехватывает его. Берёт работу на себя. Обращается к серверу, получает данные, при необходимости делает какие-то преобразования. После выполнения своей нелёгкой работы отдаёт результат редьюсеру. А дело редьюсера — управлять хранилищем и только.

Поэтому саги — это middleware, промежуточный слой между action и reducer.

Обратимся к примеру.

// src/sagas/userSaga.js
import { call, put, takeLatest } from 'redux-saga/effects'
import { User } from '../api/user'
import { login } from '../store/user'

// worker
function* loginWorker(action) {
  // принимает email и password извне
  const { email, password } = action.payload;
  const { success, fulfill } = login;

  try {
    // запросить токен, получить его, а затем попытаться запросить данные
    const { data: authData } = yield call(User.login, { email, password })
    const { token } = authData
    const { data } = yield call(User.getInfo, { token })

    // в случае успеха отдать данные редьюсеру
    yield put(success({ token, info: data.data }))
  } catch (error) {
    // ошибку можно тоже отдать редьюсеру через вызов failure
    // или получить в компоненте
    // или вообще написать функцию-обработчик, правящую миром ошибок
    console.error(error)
  } finally {
    yield put(fulfill())
  }
}

// watcher
// при срабатывании триггера login отработает и loginWorker
export function* userWatcher() {
  yield takeLatest(login.TRIGGER, loginWorker)
}

Корневая сага принимает все саги и запускает их, чтобы они начали прослушивать экшены.

// src/sagas/rootSaga.js
import { all } from 'redux-saga/effects'
import { userWatcher } from './userSaga'

export default function* rootSaga() {
  yield all([
    userWatcher(),
  ])
}

Хранилище, конечно, тоже придётся перенастроить.

// src/store/index.js
import { combineReducers } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'

import counter from './counter'
import user from './user'
import rootSaga from '../sagas/rootSaga'

// добавить sagaMiddleware
const sagaMiddleware = createSagaMiddleware()
const middleware = [...getDefaultMiddleware({ thunk: false }), sagaMiddleware];

// добавить user reducer
const rootReducer = combineReducers({
  counter,
  user,
})

// использовать middleware
const store = configureStore({
  reducer: rootReducer,
  middleware,
})

// и запустить корневую сагу
sagaMiddleware.run(rootSaga)

export default store

Вызов action в компоненте

В компоненте мало что меняется. Ну, разве что теперь в dispatch передаются внешние данные в виде логина и пароля пользователя.

// src/app.js
import { useDispatch } from 'react-redux'
import { login } from './store/user'

const userData = {
  "email": "eve.holt@reqres.in",
  "password": "cityslicka"
}

function App() {
  const dispatch = useDispatch();
  const logIn = () => dispatch(login(userData))

  return <button type="button" onClick={logIn}>log in</button>
}

export default App

Источники