I have lots of free time lately so I decided to play around a bit with React & Redux. If you want to write maintainable asynchronous code using Redux, you need to pick a middleware like redux-thunk or redux-saga.
What we're building
I love cats, so the functionality of the application is based on the Cat API. You can clone/fork the GitHub repo from here.
The application looks something like this:
If you click the "Fetch cats" button, it sends an HTTP GET request which returns a random cat image. If you click on "Fetch more cats" it returns an array of 5 random cats.
I know it's ugly and stuff but I don't really want to waste time with css. If you are interested in the full "project" and the css files as well, check out the github repo that I have already mentioned above.
The fetchCats
function will be implemented using redux-thunk and fetchMoreCats
will be written using redux-saga so that we can compare them.
Getting started
create-react-app catapi_app
Let's install some dependencies first.
npm i --save react-redux redux redux-logger redux-saga redux-thunk
Next, we need to setup redux in index.js
.
//index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import thunkMiddleware from 'redux-thunk'
import { createLogger } from 'redux-logger'
import { createStore, applyMiddleware } from 'redux'
import rootReducer from './reducers/index'
const loggerMiddleware = createLogger()
const store = createStore(
rootReducer,
applyMiddleware(
thunkMiddleware,
loggerMiddleware ))
ReactDOM.render(
<Provider store={store}>
<App/>
</Provider>,
document.getElementById('root')
);
This code will fail, because we do not have our rootReducer
. So let's continue with that.
// ./reducers/index.js
import { combineReducers } from 'redux'
import fetchCatReducer from './fetchCatReducer'
export default combineReducers({
cats: fetchCatReducer
})
We have only one reducer so far, but I like to use combineReducer because if I need to add another one, it's much easier.
This code will still fail because now, we are missing the fetchCatReducer
.
// ./reducers/fetchCatReducer.js
const fetchCatReducer = (state = [], action) => {
switch(action.type) {
case "FETCH_CATS_SUCCESS":
return [
...action.payload,
...state
]
case "FETCH_CATS_START":
return state
case "FETCH_CATS_ERROR":
return state
default:
return state
}
}
export default fetchCatReducer
Whenever we dispatch an action, that action goes through fetchCatReducer
and it updates our state accordingly.
-
"FETCH_CATS_SUCCESS"
: HTTP request was successful, we must update the state. -
"FETCH_CATS_START"
: HTTP request has been started, this is the right place to for example display a busy indicator to the user. (Loading screen or something) -
"FETCH_CATS_ERROR"
: HTTP request has failed. You can show an error component or something.
To keep the app simple, in case of "FETCH_CATS_START"
or "FETCH_CATS_ERROR"
I do nothing but returning the previous state.
Redux-thunk
Currently, our app does nothing, because we need an action creator, to fire an action that our reducer handles.
//./actions/fetchCats.js
/*Helper functions. remember, we have 3 action types so far,
these functions return a plain object that has a
type attribute that our reducer can handle.
in case of success request,
the action has a payload property as well.
That's the response cat from the server
that we have requested*/
const fetchCatsError = () =>{
return {type: "FETCH_CATS_ERROR"}
}
const fetchCatsStarted = () =>{
return {type: "FETCH_CATS_START"}
}
const fetchCatsSuccess = (cat) => {
return {type: "FETCH_CATS_SUCCESS", payload: cat}
}
// fetching a random cat starts now
const fetchCats = () => dispatch => {
dispatch(fetchCatsStarted())
fetch("https://api.thecatapi.com/v1/images/search",{
headers: {
"Content-Type": "application/json",
"x-api-key": "YOUR_API_KEY"
}
})
.then( catResponse => catResponse.json())
.then( cat => dispatch(fetchCatsSuccess(cat)) )
.catch( err => dispatch(fetchCatsError()))
}
Ye, in order to use this endpoint on CAT API, you need an api key.
fetchCats
might look strange at first, its basically a function that returns another function that has a parameter dispatch
. Once you call dispatch, the control flow will jump to your reducer to decide what to do. In our case, we only update our application state, if the request has been successful. Btw, that's why I have installed redux-logger
. It constantly logs the changes of your state, and actions so its much easier to follow what's happening.
If you prefer the Async/await syntax, then you can implement the above function like this:
const fetchCats = () => async dispatch => {
dispatch(fetchCatsStarted())
try{
const catResponse = await fetch("https://api.thecatapi.com/v1/images/search",{
headers: {
"Content-Type": "application/json",
"x-api-key": "YOUR_API_KEY"
}
})
const cat = await catResponse.json()
dispatch(fetchCatsSuccess(cat))
}catch(exc){
dispatch(fetchCatsError())
}
}
App component
I don't want this post to be too long, so I skip the implementations of the components. I'll show you how the App.js
looks like, if you are interested in the complete code, check it out on GitHub.
//./components/App.js
import React, { Component } from 'react'
import Button from './proxy/Button'
import CatList from './CatList'
import '../css/App.css'
import { connect } from 'react-redux'
import fetchCats from '../actions/fetchCats'
class App extends Component {
render() {
return (
<div className="App">
<Button className="primary" text="Fetch cats" onClick={this.props.fetchCats}/>
<Button className="secondary" text="Fetch more cats"/>
<header className="App-header">
<CatList cats={this.props.cats}/>
</header>
</div>
)
}
}
const mapStateToProps = (state, ownProps) => ({
cats: state.cats
})
export default connect(mapStateToProps, { fetchCats })(App);
Redux-saga
Redux-saga is a redux middleware that allows us to easily implement asynchronous code with redux.
To initialize it, we need to adjust our index.js
a bit.
//./index.js
...
import createSagaMiddleware from 'redux-saga'
import watchFetchMoreCatsSaga from './saga/fetchMoreCats'
//init
const sagaMiddleware = createSagaMiddleware()
//run
sagaMiddleware.run(watchFetchMoreCatsSaga)
...
In the saga
folder, create a new file called fetchMoreCats
.
//./saga/fetchMoreCats
import { takeLatest, put } from "redux-saga/effects";
//Every time we dispatch an action
//that has a type property "FETCH_MORE_CATS"
// call the fetchMoreCatsSaga function
export default function* watchFetchMoreCatsSaga(){
yield takeLatest("FETCH_MORE_CATS", fetchMoreCatsSaga)
}
//query 5 cat image at the same time
function* fetchMoreCatsSaga(){
yield put({type: "FETCH_MORE_CATS_SAGA_START"})
const catResponse = yield fetch("https://api.thecatapi.com/v1/images/search?limit=5",{
headers: {
"Content-Type": "application/json",
"x-api-key": "YOUR_API_KEY"
}
})
const cats = yield catResponse.json()
yield put({type: "FETCH_MORE_CATS_SAGA_SUCCESS", payload: cats})
}
Those function*
thingies are called generator functions. If you want to know more about them, click here.
The takeLatest
function can be replaced by takeEvery
for example, but one cool feature of takelatest
is that it only takes the last "event". In our case, if we rapidly click the button like 100 times, then our app sends 100 requests pretty much DDOSing the API :D. So instead of disabling the button every time it's getting clicked, we can use takeLatest
.
As you can see, by calling the put
function we can fire actions just like we did with dispatch
. So let's adjust our ./reducers/fetchCatReducer.js
to handle our new saga actions.
//./reducers/fetchCatReducer.js
...
case "FETCH_MORE_CATS_SAGA_SUCCESS":
return [
...action.payload,
...state
]
case "FETCH_MORE_CATS_SAGA_START":
return state
case "FETCH_MORE_CATS_SAGA_ERROR":
return state
...
The watchFetchMoreCatsSaga
generator function is constantly listening to the "FETCH_MORE_CATS"
action and calls our fetchMoreCatsSaga
. So in order to make this work, we need to first fire that action.
//./actions/fetchMoreCats.js
const fetchMoreCats = () => dispatch =>{
dispatch({type: "FETCH_MORE_CATS"})
}
export default fetchMoreCats
That's it. Every time we call fetchMoreCats
, it dispatches {type: "FETCH_MORE_CATS"}
which "invokes" our watchFetchMoreCatsSaga
that calls fetchMoreCatsSaga
.
So we need to import fetchMoreCats
in our App.js
and call it when the user clicks that button.
//App.js
...
import fetchMoreCats from '../actions/fetchMoreCats'
//put this button in the render method
<Button className="secondary" text="Fetch more cats" onClick={this.props.fetchMoreCats}/>
//we need to map that function to the props of the App
export default connect(mapStateToProps, { fetchCats, fetchMoreCats })(App);
The end
If you want to know more: Saga documentation
If you have any questions, please let me know in the comment section or feel free to email me.
Top comments (11)
Thanks for the nice article. Since you're already familiarized yourself with thunks and sagas, you should probably learn about epics in redux-observables. I find them a cleaner abstraction of side effects in redux than the other options. The only problem is that they are so powerful, you'll soon end up using epics for everything.
Everyone keeps recommending redux-observables, will definitely check it out.
Hi Alex, thanks for the hint I'll definitely check it out!
Awesome Article, great to see the Cat API being used to teach, thanks Norbert!
Thanks, Aden great api by the way :)
Awesome stuff. When deciding between the two I chose Redux-Thunk just because it clicked faster. I've been curious about Saga lately, what's the advantage?
Hi Jordan, thank you!
I think the main advantage saga has over thunk is the testability. Generator functions return iterators. So you can pretty easily see, what's going on in your function by calling .next().
Here's an example to test sagas: redux-saga.js.org/docs/basics/Disp...
OR
redux-saga.js.org/docs/advanced/Te...
In depth article on redux-thunk and redux-saga. Thanks for writing wonderful article.
Thanks, glad you like it. :)
Wonderful article!
Thanks u
I'd love it if you wanted to dissect my Redux one line replacement hook... useSync
dev.to/chadsteele/redux-one-liner-...