11 KiB
title |
---|
Redux Sagas |
Introduction
In this guide it will be presented to the reader the basic concept of Redux Sagas, coupled with a minimal working example.
It's assumed that the reader has already grasped the basic concepts of Redux and React.
Definition
Ranging from fetching content from the browser local storage to fetching data from HTTP call or from GraphQL server, Redux Sagas help any Redux application to achieve this in a more organized and efficient way.
A analogy that can be used to ilustrate what Redux Sagas are, is to think of a Saga like a separate process running side by side with the application and can be controlled via Redux actions.
Simple Example
Bellow will be presented to the reader a very simple example that covers some of the key features of this library.
Assuming that the Redux Tutorial was followed and a similar project structure is present.
project_root
│ index.js
|
└───client
| App.jsx
| NotFound.jsx
|
└───common
│
└───actions
| │ appActions.js
|
└───constants
| │ Actiontypes.js
|
└───reducers
| │ appReducer.js
└───store
│ store.js
Installation
Start by adding the library to the project.
With npm by issuing the command:
npm install --save redux-saga
Or with yarn:
yarn add redux-saga
Also for the scope of this guide it will be used the axios library that handles Promise based API calls, but any other is acceptable.
Redux Changes
It will be necessary to make some changes to the tutorial to accommodate the addition of Sagas.
Start by modifying the Actiontypes.js
file located in the constants folder and add the following code.
export const API_REQUEST = "API_REQUEST";
export const API_SUCCESS = "API_SUCCESS";
export const API_FAILURE = "API_FAILURE";
Modify the appReducer.js
file located in the reducers folder to contain the code bellow.
import {
API_REQUEST,
API_SUCCESS,
API_FAILURE
} from '../constants/Actiontypes';
// initial state of the reducer
const initialState = {
fetching: false,
data: null,
error: null
};
// the reducer
export const reducer=(state = initialState, action)=>{
switch (action.type) {
case API_REQUEST:
return { ...state, fetching: true, error: null };
case API_SUCCESS:
return { ...state, fetching: false, data: action.data };
case API_FAILURE:
return { ...state, fetching: false, data: null, error: action.error };
default:
return state;
}
};
Saga Implementation
After the changes made to the Redux part of the application, now time to move onto the Saga implementation.
Inside the common folder create a new one named sagas, and inside that folder a new file named sagas.js
with the following code.
import { takeLatest, call, put } from "redux-saga/effects"; // helper functions imported from redux-sagas
import axios from "axios"; // promise library used for the guide
// watcher saga: the functon that watches for actions dispatched to the store and starts worker saga
export function* watcherSaga() {
yield takeLatest("API_REQUEST", workerSaga);
}
// function that makes the api request and returns a Promise for response
function fetchData() {
return axios({
method: "get",
url: "https://your_api_endpoint"
});
}
// worker saga: makes the api call when watcher saga sees the action
function* workerSaga() {
try {
const response = yield call(fetchData);
const data = response.data.message;
// dispatch a success action to the store with the new data
yield put({ type: "API_SUCCESS", data });
} catch (error) {
// dispatch a failure action to the store with the error
yield put({ type: "API_FAILURE", error });
}
}
While reading the code above, the reader might notice some things that are different from standard javascript code.
One is the function*
syntax. Using this creates a special kind of function called a generator.
These functions have a special feature built in, and that is, they can be paused and restarted and also remember the state over time.
Also another diference is the yield
keyword inside these functions, it represents a asynchronous step in a synchronous/sequencial process.
An analogy that can be applied to the yield
keyword is to to think of it as the await
in the await/async pattern.
Breaking the saga code into smaller pieces will result in the following:
-
The
watcherSaga
generator function is what will watch for any action dispatched to the store and will trigger theworkerSaga
function. -
takeLatest is a helper function built in the library that will trigger a new
workerSaga
when aAPI_REQUEST
action is triggered, while cancelling any previously triggered ones still being processed. -
The
fetchData
function will make use of the axios library to make a request to a given API endpoint and returns a Promise as a response. -
The
workerSaga
generator function will attempt tofetchData
, using the other imported helper function call and will store the result. -
If the
fetchData
function succeed, it's result will be extracted from the response and sent as the payload of the actionAPI_SUCCESS
, using theput
helper function that was imported. -
If there was an error with the request. The store is notified via the action
API_FAILURE
sending the error as the payload.
Connect React with Redux and Redux-Saga
Now that a simple Redux Saga is implemented, now to proceed in connecting all the parts.
Open the store.js
file located inside store folder, and modify it by adding the following code:
import { createStore, applyMiddleware, compose } from "redux";
import reducer from '../reducers/ExampleAppReducer'; // the reducer created
import createSagaMiddleware from "redux-saga"; // Redux Saga middleware
import { watcherSaga } from "../sagas/sagas"; // imports the watched saga defined earlier
// create the saga middleware
const sagaMiddleware = createSagaMiddleware();
// dev tools middleware
const reduxDevTools =
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__();
export default createStore(
reducer,
compose(applyMiddleware(sagaMiddleware), reduxDevTools)
);
// run the saga
sagaMiddleware.run(watcherSaga);
The index.js
located at the project_root folder should look like this.
import React from "react";
import ReactDOM from "react-dom";
import {Router,Route,browserHistory} from 'react-router';
import store from "../common/store/store";
import App from '../client/App';
import NotFound from '../client/NotFound';
import { Provider } from "react-redux";
ReactDOM.render(
<Provider store={store}>
<Router history={browserHistory}>
<Route path="/" component={App}/>
<Route path="*" component={NotFound}/>
</Router>
</Provider>,
document.getElementById("root")
);
The app.js
file inside the client folder needs to be modified in order to reflect the changes made to the application.
Bellow is a simple React Component connected to redux that will make use of what was implemented.
import React, { Component } from "react";
import { connect } from "react-redux";
class App extends Component {
render() {
const { fetching, item, onRequestData, error } = this.props;
return (
<div>
<header>
<h1>Redux Saga Guide</h1>
</header>
<h4>{item}</h4>
{fetching ? (
<button disabled>Fetching...</button>
) : (
<button onClick={onRequestData}>Make a async request to your saga</button>
)}
{error && <p style={{ color: "red" }}>Uh oh - something went wrong!</p>}
</div>
);
}
}
/**
* es6 fat arrow function to get information from the application state
* @param {*} state current state of the application
*/
const mapStateToProps = state => {
return {
fetching: state.fetching,
item: state.data,
error: state.error
};
};
/**
* es6 fat arrow function to connect the actions declared in the saga file to the the component as if they were common react props
* @param {*} dispatch function send to action file to be later processed in the reducer
*/
const mapDispatchToProps = dispatch => {
return {
onRequestData: () => dispatch({ type: "API_REQUEST" })
};
};
/**
* this function connects the application component to be possible to interact with the store
* @param {*} mapStateToProps allows the retrieval of information from the state of the application
* @param {*} mapDispatchToProps allows the state to change via the actions defined inside
*/
export default connect(mapStateToProps, mapDispatchToProps)(App);
In summary
Now that everything is connected, what will happen every time a user interacts with the application is the following:
- An event takes place — e.g. user does something (clicks “onRequestData” button) or an update occurs (like componentDidMount).
- Based on the event, an action is dispatched, likely through a function declared in
mapDispatchToProps
(e.g.onRequestData) - A
watcherSaga
sees the action and triggers aworkerSaga
. Use saga helpers to watch for actions differently. - While the saga is starting, the action also hits a reducer and updates some piece of state to indicate that the saga has begun and is in process (e.g. fetching).
- The
workerSaga
performs some side-effect operation (e.g.fetchData
). - Based on the result of the
workerSaga‘s
operation, it dispatches an action to indicate that result. If it's successful then (API_SUCCESS
), with a payload of the data recieved. If an error (API_FAILURE
), you might send along an error object for more details on what went wrong. - The reducer handles the success or failure action from the
workerSaga
and updates the store accordingly with any new data, as well as sets the “in process” indicator (e.g. fetching) to false.