Example #3 - Sliders
This example demonstrates side effects through sagas.
We will build a slider that will update its image every 5 seconds.
Final result
The final result will look like this:
Whenever you press any of the dots the 5 second counter will reset.
0. Install kea-saga
We'll be using sagas in this example. To add support for them, install the kea-saga
and redux-saga
packages.
# if using yarn
yarn add kea-saga redux-saga
# if using npm
npm install kea-saga redux-saga --save
Then import sagaPlugin
from kea-saga
in your store.js
and add it to the plugins array in getStore()
import sagaPlugin from 'kea-saga'
const store = getStore({
plugins: [
sagaPlugin
]
})
1. The static component
Based on the knowledge from the previous chapters, you should be able to build a static slider.
The code for it will look something like this:
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'
import images from './images' // array of objects [{ src, author }, ...]
import range from '~/utils/range' // helper, range(3) === [0, 1, 2]
@kea({
actions: () => ({
updateSlide: index => ({ index })
}),
reducers: ({ actions }) => ({
currentSlide: [0, PropTypes.number, {
[actions.updateSlide]: (state, payload) => payload.index % images.length
}]
}),
selectors: ({ selectors }) => ({
currentImage: [
() => [selectors.currentSlide],
(currentSlide) => images[currentSlide],
PropTypes.object
]
})
})
export default class Slider extends Component {
render () {
const { currentSlide, currentImage } = this.props
const { updateSlide } = this.actions
const title = `Image copyright by ${currentImage.author}`
return (
<div className='kea-slider'>
<img src={currentImage.src} alt={title} title={title} />
<div className='buttons'>
{range(images.length).map(i => (
<span key={i}
className={i === currentSlide ? 'selected' : ''}
onClick={() => updateSlide(i)} />
))}
</div>
</div>
)
}
}
Giving us the following result:
Click it! It works, but won't change the slides automatically.
2. Just Add Sagas
For that we need to write some sagas.
Sagas provide a nice way to write code that has side effects. They might be unfamiliar at first, but when you get to know them, you'll wonder how you ever wrote your frontend code without them.
First we must import the redux-saga/effects
that we need. We'll also import a delay
effect that just sleeps for the given amount of milliseconds.
import { take, race, put } from 'redux-saga/effects'
import delay from '~/utils/delay'
And then we create a start
generator function inside @kea({})
. This function is called every time your component is mounted.
Inside this start
function we create a race
condition between two competing effects: a delay of 5 seconds and the action updateSlide
being triggered.
The code pauses at the yield race()
call until one of the two conditions is met.
In case the timeout won the race, we fetch the latest slide (yield this.get(..)
is a shorthand to use the selectors defined in @kea({})
)... and then we dispatch (yield put()
) the updateSlide
action with the next slide.
@kea({
actions: () => ({
updateSlide: index => ({ index })
}),
// ...
// run this saga when the component is mounted
start: function * () {
const { updateSlide } = this.actions
while (true) {
const { timeout } = yield race({
change: take(updateSlide.toString()),
timeout: delay(5000)
})
if (timeout) {
const currentSlide = yield this.get('currentSlide')
yield put(updateSlide(currentSlide + 1))
}
}
}
})
export default class StaticSlider extends Component {
// as it was
}
All of this results in this:
Now if you will always just have one slider on your screen, you're done. If you wish to have many slider instances, you will run into the same issue as with the dynamic counter example - both sliders will listen to and react to the updateSlide
actions unless you explicitly prohibit them.
The code below demonstrates a way to prevent this from happening. It also shows how to listen to actions using the takeEvery
helper and the workers
object.
Full source
Better documentation is coming one day. Until then, read the comments in the code and the redux-saga documentation.
// slider/index.js
import './styles.scss'
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'
import { take, race, put } from 'redux-saga/effects'
import delay from '~/utils/delay'
import range from '~/utils/range'
import images from './images'
@kea({
key: (props) => props.id,
path: (key) => ['scenes', 'homepage', 'slider', key],
actions: () => ({
updateSlide: index => ({ index })
}),
reducers: ({ actions, key, props }) => ({
currentSlide: [props.initialSlide || 0, PropTypes.number, {
[actions.updateSlide]: (state, payload) => {
return payload.key === key ? payload.index % images.length : state
}
}]
}),
selectors: ({ selectors }) => ({
currentImage: [
() => [selectors.currentSlide],
(currentSlide) => images[currentSlide],
PropTypes.object
]
}),
// This saga is run when the component is mounted.
// The function is a regular redux-saga worker that has access to:
// 1) this.actions, 2) this.key and 3) this.props
//
// Read the redux-saga documentation to understand the different
// functions like: race(), put(), take(), etc
start: function * () {
const { updateSlide } = this.actions
console.log('Starting homepage slider saga')
// console.log(this, this.actions, this.props)
while (true) {
// wait until the updateSlide() action is triggered or a 5sec timeout occurs
// to ignore actions from other slider instances we must also match the key
const { timeout } = yield race({
change: take(action => action.type === updateSlide.toString() &&
action.payload.key === this.key),
timeout: delay(5000)
})
if (timeout) {
// use this.get(..) to select the latest data from redux
const currentSlide = yield this.get('currentSlide')
// actions are not automatically bound to dispatch, so
// you must use redux-saga's put() with them
yield put(updateSlide(currentSlide + 1))
}
}
},
// this saga is run when the component is unmounted
stop: function * () {
console.log('Stopping homepage slider saga')
},
// The redux-saga takeEvery function.
// It waits for actions and runs the relevant functions.
// Also available: takeLatest
takeEvery: ({ actions, workers }) => ({
[actions.updateSlide]: workers.updateSlide
}),
// it's recommended to group all the logic under the workers: {} object.
workers: {
updateSlide: function * (action) {
// check if it was this component that triggered the action
if (action.payload.key === this.key) {
console.log('slide update triggered', action.payload.key, this.key, this.props.id)
// console.log(action, this)
}
}
}
})
export default class Slider extends Component {
render () {
const { currentSlide, currentImage } = this.props
const { updateSlide } = this.actions
const title = `Image copyright by ${currentImage.author}`
return (
<div className='kea-slider'>
<img src={currentImage.src} alt={title} title={title} />
<div className='buttons'>
{range(images.length).map(i => (
<span key={i}
className={i === currentSlide ? 'selected' : ''}
onClick={() => updateSlide(i)} />
))}
</div>
</div>
)
}
}
// index.js
export default class SlidersScene extends Component {
render () {
return (
<div className='slider-container'>
<Slider id={1} initialSlide={0} />
<Slider id={2} initialSlide={1} />
</div>
)
}
}
Next page: Github API