Background
My team uses react, redux and redux-saga for frontend. I had never used redux-saga (a redux middleware) before I joined this team last month and needed to figure out how it works. Reading and taking down notes is important, but sometimes writing and testing codes are more intuitive to learn.
So I created a very simple react, redux-saga app to switch logo on button click. It utilizes unsplash api.
What is Redux-Saga?
It is a redux middleware which uses ES6 generators to execute asynchronous logic. It is a good way to avoid callback hell.
0. Prerequisite
Go to Unsplash to apply for an API access key.
1. Getting started
Start a react app and install necessary libs
create-react-app switcharoo
cd switcharoo
yarn add axios redux redux-saga react-redux
2. Create folders and files
root
|-- .gitignore
|-- README.md
|-- package.json
|-- yarn.lock
|-- public
| |-- favicon.ico
| |-- index.html
| |-- manifest.json
| |-- rawr.ico
|-- src
|-- App.css
|-- App.js
|-- directoryList.md
|-- index.css
|-- index.js
|-- logo.svg
|-- serviceWorker.js
|-- __tests__
| |-- sagas.test.js
|-- api
| |-- .config.js
| |-- index.js
|-- redux
| |-- actions.js
| |-- constants.js
| |-- reducer.js
|-- sagas
|-- index.js
3. Use unsplash api
Create a config file under api folder. Don’t forget to add it to your .gitignore! Then, write a function to fetch a random image via Unsplash API.
/* src/api/.config.js */
export const ACCESS_KEY =
"your_key_here";
/* src/api/index.js */
import axios from 'axios';
import { ACCESS_KEY } from './.config.js';
const unsplash = axios.create({
baseURL: 'https://api.unsplash.com',
headers: {
Authorization: `Client-ID ${ACCESS_KEY}`
}
});
export const searchPhoto = () => unsplash.get('/photos/random');
4. Time to churn out the usual codes for redux
/* src/redux/constants.js */
export const FETCH_IMAGES_CALLED = "FETCH_IMAGES_CALLED";
export const FETCH_IMAGES_SUCCESS = "FETCH_IMAGES_SUCCESS";
export const FETCH_IMAGES_FAILURE = "FETCH_IMAGES_FAILURE";
/* src/redux/actions.js */
import {
FETCH_IMAGES_CALLED,
FETCH_IMAGES_SUCCESS,
FETCH_IMAGES_FAILURE
} from "./constants";
export const fetchImages = () => {
return {
type: FETCH_IMAGES_CALLED
};
};
export const fetchImagesSuccess = image => {
return {
type: FETCH_IMAGES_SUCCESS,
image
};
};
export const fetchImagesFailure = error => {
return {
type: FETCH_IMAGES_FAILURE,
error
};
};
/* src/redux/reducer.js */
import {
FETCH_IMAGES_CALLED,
FETCH_IMAGES_SUCCESS,
FETCH_IMAGES_FAILURE
} from "./constants";
const initialState = {
fetching: false,
img: null,
error: null
};
export const reducer = (state = initialState, action) => {
switch (action.type) {
case FETCH_IMAGES_CALLED:
return { ...state, fetching: true, error: null };
case FETCH_IMAGES_SUCCESS:
return { ...state, fetching: false, img: action.image };
case FETCH_IMAGES_FAILURE:
return { ...state, fetching: false, error: action.error };
default:
return state;
}
};
5. Time for the new part! Redux-Saga!
/* src/sagas/index.js */
import { takeLatest, call, put } from "redux-saga/effects";
import { searchPhoto } from "../api/index";
import { FETCH_IMAGES_CALLED } from "../redux/constants";
import { fetchImagesSuccess, fetchImagesFailure } from "../redux/actions";
export function* watcher() {
// when this action is called, a callback generator function is run
yield takeLatest(FETCH_IMAGES_CALLED, worker);
}
function* worker() {
try {
// instructs middleware to call a function or another saga
const response = yield call(searchPhoto);
const image = response.data.urls.regular;
// instructs middleware to dispatch an action to the Store
yield put(fetchImagesSuccess(image));
} catch (error) {
yield put(fetchImagesFailure(error));
}
}
export function* testWorker() {
// this will be used only for unit test purpose
try {
yield call(searchPhoto);
yield put(fetchImagesSuccess({ status: 200 }));
} catch (error) {
yield put(fetchImagesSuccess({ error }));
}
}
6. Finishing touch
/* App.js */
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import { connect } from 'react-redux';
import { fetchImages } from './redux/actions';
const mapStateToProps = state => {
// called every time the store state changes
return {
fetching: state.fetching,
img: state.img,
error: state.error
};
};
const mapDispatchToProps = dispatch => {
// used for dispatching actions to the store
// this is the only way to trigger a state change
return {
onRequest: () => dispatch(fetchImages())
};
};
export default connect(
// connect a React component to a Redux store
mapStateToProps,
mapDispatchToProps
)(App);
class App extends Component {
render() {
const { fetching, img, onRequest, error } = this.props;
return (
<div className="App">
<header className="App-header">
<img src={img || logo} className="App-logo" alt="logo" />
<h1 className="app-title">Switcharoo</h1>
</header>
{fetching ? (
<button className="button" disabled>
Fetching...
</button>
) : (
<button onClick={onRequest}>NEW PIC</button>
)}
{error && <p style={{ color: 'red' }}>Error!</p>}
</div>
);
}
}
Testing
import { call, put } from 'redux-saga/effects';
import { searchPhoto } from '../api';
import { testWorker } from '../sagas';
import { fetchImagesSuccess } from '../redux/actions';
describe('test worker', () => {
it('handles a successful image fetch', () => {
const init = testWorker();
expect(init.next().value).toEqual(call(searchPhoto));
expect(init.next().value).toEqual(put(fetchImagesSuccess({ status: 200 })));
});
});
Conclusion
Code looks cleaner because logic is less scattered and a callback hell doesn't exist anymore. Also, it seems easier to unit test now. You can see the complete code here.
Reference
Redux-Saga Official Doc Redux-saga tutorial for beginners and dog lovers