Learning Redux Saga: Event Channels with Web Midi

Joel Bond
5 min readJan 7, 2018

--

At work we recently started using Redux Saga to handle the side-effects of our React/Redux app. For the most part we’ve been using the middleware in a pretty straightforward way, watching with takeEvery and then making api calls with call or apply effects and eventually getting the data into the store with put. But I know that Saga is much more powerful than just that. I wanted to take some time to look into some of the more advanced functionality. In this post we will look at event channels.

Without channels, Saga provides a straightforward performing asynchronous calls and waiting for a response from that specific call. But what if we want to listen to an unspecified number of events from source outside of our React components? Saga’s event channels make this possible.

An example

The typical example for using an event channel is listening to messages over a web socket. But let’s try something a little more interesting. Say we are creating a web audio synthesizer or some sort of music training app that needs to listen to incoming MIDI events and display some values about those events.

One way to accomplish this would be to simply set up the listener in the UI. you could start listening for midi events on componentDidMount and either keep track of the note values in local component state or dispatch actions with payloads that eventually make their way back to the component as props, possibly after some processing.

import React, { Component } from "react";
import { onMidiMessage } from "my-action-creators-file";
class MidiDisplay extends Component {
componentDidMount() {
const midiAccess = window.navigator.requestMIDIAccess({
sysex: false
});
// loop through inputs
// assign onMidiMessage action creator to each
// etc...
}
render() {
// display notes
}
}
const mapStateToProps = state => ({
notes: state.app.notes
});
export default connect(mapStateToProps, { onMidiMessage })(MidiDisplay);

But this makes our UI component brittle and difficult to test. It’s a lot of specific Web MIDI implementation code mixed with our DOM UI code, especially if we want to do any sort of processing of the messages. We want to keep our components (even our connected containers) as light and dumb as we can. The component displaying the information should be concerned with just that, display:

import React from "react";export default ({ notes }) => (
<div>Notes: {Object.keys(notes).join(", ")}</div>
);

Here we have component that takes a dictionary where the note values are the keys and values are the velocity at which the note was played. For now we will simply display the notes that are currently being played, separated by commas.

We’ll then wrap this component in a container that gets the note/velocity dictionary from the store and hands it down to this component as a prop:

import React, { Component } from "react";
import { connect } from "react-redux";
import { appMounted } from "my-action-creators-file";
import MidiDisplay from "../components/MidiDisplay";
class App extends Component {
componentDidMount() {
this.props.appMounted();
}

render = () => <MidiDisplay notes={this.props.notes} />;
}
const mapStateToProps = state => ({
notes: state.app.notes
});
export default connect(mapStateToProps, { appMounted })(App);

The other thing that happens here is that when our app loads up we fire an action that our saga will use to do the midi initialization. Let’s look at some saga code:

function* appMountedSaga() {
if (window.navigator.requestMIDIAccess) {
try {
const midiAccess = yield call(
[window.navigator, window.navigator.requestMIDIAccess],
{
sysex: false
}
);
yield call(onMidiSuccess, midiAccess);
} catch (error) {
yield put({ type: MIDI_UNSUPPORTED });
console.error(error);
}
}
}
export const appSagas = [takeEvery(APP_MOUNTED, appMountedSaga)];

The APP_MOUNTED action from our container triggers the appMountedSaga. In this file we just export an array of calls to takeEvery (just one here). I’ve gotten into the habit of exporting sagas as arrays. This way I can combine them into a rootSaga without caring how many each domain is actually exporting:

export function* rootSaga() {
yield all([
...appSagas,
...otherSagas,
...otherOtherSagas
]);
}

appMountedSaga requests midi access. If the browser doesn’t support midi or if there is any error getting access we will add some errors to the store which we may want to display later to the user. Otherwise we call onMidiSuccess.

function* onMidiSuccess(midiAccess) {
const channel = yield call(createMidiEventChannel, midiAccess);

while (true) {
const message = yield take(channel);
yield call(onMidiMessage, message);
}
}

Here we create our event channel and create an endless loop to take every event that is emitted from it. Remember that since we are in a generator, this is not going to blow up. For each iteration, we yield until take receives a value from the channel. The message that is emitted gets passed to onMidiMessage which will do some processing before putting and action to the store. Let’s look at the function that creates the channel:

function createMidiEventChannel(midiAccess) {
return eventChannel(emitter => {
const inputs = midiAccess.inputs.values();
for (
let input = inputs.next();
input && !input.done;
input = inputs.next()
) {
// each time there is a midi message call the onMIDIMessage
// function
input.value.onmidimessage = emitter;
}
// The subscriber must return an unsubscribe function. We'll
// just return no op for this example.
return () => {
// Cleanup event listeners. Clear timers etc...
};
});
}

Here we return a new channel via eventChannel() from Redux Saga. We pass in a callback that takes an emitter functions and describes when and how it should be called. midiAccess.inputs.values() returns an iterator that we can use to loop through all available MIDI inputs and add a listener for messages. We’ll set our emitter callback as the onmidimessage method for each input. Note that you need to return an unsubscribe function so that the channel can clean up after itself when it is closed. For this example we will leave it as a no op since we want to keep the channel open.

Here is the entire redux module (see ducks pattern), including the reducer and actions:

That’s it!

I hope you find this example helpful. For me, I tend to think of redux middleware as something to handle network calls. But actually, it’s useful for any sort of I/O that is going to make your UI code less pure and declarative. By moving the MIDI messaging code out of the UI code we were able to clean things up and Redux Saga event channels helped us do it.

--

--