У разработчиков двойственные впечатления о 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