WebSockets in Redux: Using Sagas

Writing an event channel listener with Redux Saga.

By Ron Gierlach

In part one we wrote our own Redux middleware to manage WebSockets. In this post I'll be covering a second approach using Redux Saga.

For those of you unfamiliar with Redux Saga this could get a little confusing, however, some cursory examination of the documentation should get you up to speed.

At their basis, Sagas boil down to a pattern for handling side-effects using ES6 Generator functions. They aim to replace where otherwise you may have used a middleware like Redux Thunk in combination with async / await or just some good ol' fashioned callbacks.

The main strategy in the case of an external event source like a WebSocket is to use the eventChannel factory function -- it will take a subscriber function that establishes the event source (our WebSocket) and then emits the relevant events to our expectant saga. Read more on this here.

Presuming the same action types and action creators from the first post, let's write our Channel factory below:

import { eventChannel } from 'redux-saga'
import { bindActionCreators } from 'redux'

const connectSocket = ({
  socketURL, // the url our socket connects to
  subscribeData, // the handshake data our socket will send once connected (optional)
  eventHandlers // the actions we want our socket to dispatch
}) => eventChannel(
  emitter => {
    // instantiate the web socket
    const ws = new window.WebSocket(socketURl)
    // bind eventHandlers to emitter
    const boundEventHandlers = bindActionCreators(eventHandlers, emitter)
    // emit onopen event, and fire off a subscribe message with our handshake data
    ws.onopen = e => {
      boundEventHandlers.onopen(e)
      ws.send(JSON.stringify({ type: 'subscribe', ...subscribeData }))
    }
    // assign remaining event handlers
    ws.onclose = boundEventHandlers.onclose
    ws.onerror = boundEventHandlers.onerror
    ws.onmessage = boundEventHandlers.onmessage
    return ws.close
  }
)

You've probably already noticed that the body of our Channel is nearly identical to the body of our middleware from the first post!

If you're still with me, let's take our Channel to task and use it in a Saga:

import { effects, takeEvery } from 'redux-saga'
const { call, put, take } = effects

// saga for our WebSocket
const socketSaga = function * (action) {
  const socketChannel = yield call(connectSocket, action.payload)
  while (true) {
    const eventAction = yield take(socketChannel)
    yield put(eventAction)
  }
}

// rootSaga for our store, listens for 'SOCKET_CONNECT' dispatch
const rootSaga = function * (action) {
  yield takeEvery(types.SOCKET_CONNECT, socketSaga)
}

Look at that while loop, what the heck is going on here?! It's actually not so crazy, I promise.

The yield flag causes execution inside the generator function to pause (think of it as a generator function's version of a return statement) before resuming iteration. This pattern allows us to respond to a constant stream of WebSocket-initiated action dispatches as they fire.

With our sagas at the ready, we just need to change our previously written socketConnect action to return a payload with all our relevant WebSocket instantiation arguments.

const actionCreators = {
  /* ...other action creators... */
  socketConnect: opts => ({
    type: types.SOCKET_CONNECT,
    payload: {
      socketURL: opts.socketURL,
      subscribeData: opts.subscribeData,
      eventHandlers: opts.eventHandlers
    }
  })
}

 Note that these arguments could have been declared in the Channel factory itself or really at any stage prior to socket instantiation.

The only step left will be for you to add Redux Saga middleware to your store, instructions to which may be found here.

I hope you've found at least one of these approaches helpful and can start using WebSockets with your Redux application today!