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:

Image copyright by Kevin Glisson

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
  ]
})

Read here for more

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:

Image copyright by Kevin Glisson

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:

Image copyright by Kevin Glisson

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