Redux Sagas – Event Driven Programming is Back!

Let’s go back in time about 15 years. VB6 is the not so much the hotness but a go-to option for Windows desktop applications. Heck, I was at high school finding out the hard way you don’t want 15 instances of the Windows Media Player OCX in a form and might want to use bass.dll instead. Windows 98’s lack of pre-emptive multitasking didn’t exactly help affairs.

The VB6 masterpiece that originally acted as my Advanced Higher Computing project. I almost feel sorry for the person that got the doorstep of code printed out to assess.

There are a ton of programming paradigms. Object oriented is very common though I have seen the odd bit of functional programming out in the wild. In the days I was taught VB, event-driven programming was how we were pitched it. Your code simply sits there waiting for event such as button presses. Node.js would be a more modern example of this approach.

That said, I’ve been working with Redux Sagas recently and it feels a lot like the event driven programming of old. Jumping straight to Redux Sagas without touching on what Redux is seems a bit odd so let’s jump back a step or two.

I’m using React Native to develop the new Solid Radio mobile app. There are many reasons I chose it over Xamarin but the Redux intergration has been a nice thing to have. Redux provides a global, read-only state in a single store. The only thing that changes the state are pure functions (called “reducers”). Calls to these functions are dispatched as events with parameters.

It’s not quite pure functional programming. We can’t do fancy things like curry or compose but testing is simple. Unit test the function with a good range of parameters, including those horrible boundary cases, and you’re good to go.

Sagas allow us to go a step further. We can now trigger long (or short) running events when certain events are dispatched. As an analytics feature, we may wish to log every time a setting toggle is changed by the end user. With the Solid Radio app, code is listening for web socket events, audio player events (stop, buffer, pause, etc.) and even web request responses during the initial load (grabbing the EPG would be an example).

Talking of examples, let’s look at hooking up a built-in WebSocket to Redux Sagas through channels:

/**
 * Creates a web socket connection to a station.
 * @param {string} server The server to connect to.
 * @param {string} station The station to open the socket for.
 */
function connectToStationSocket(server, station) {
    return new Promise((resolve, reject) => {
        const uriEncodedStationName = encodeURI(station);
        let socket = new WebSocket(`https://${server}/nowplaying/${uriEncodedStationName}/`);
        socket.onopen = () => {
            resolve(socket);
        };
        socket.onerror = (error) => {
            reject(error);
        };
    });
}
/**
 * Creates a Redux Saga event channel for a web socket.
 * @param {WebSocket} socket The socket to create the event channel from.
 */
function createStationSocketChannel(socket) {
    return eventChannel(emit => {
        // Pass websocket messages straight though
        socket.onmessage = (event) => {
            emit(event.data);
        };
        // Close the channel as appropriate
        socket.onclose = () => {
            emit(END);
        };
        const unsubscribe = () => {
            socket.onmessage = null;
        };
        return unsubscribe;
    });
}
/**
 * The actual now playing saga for a station.
 * @param {action} action The station load success action.
 */
function* nowPlayingSaga(action) {
    // Get some basics together
    let stationName = action.payload.data.name;
    const state = yield select();
    const server = state.api.server;
    let socket;
    let channel;
    try {
        // Make our connection
        socket = yield call(connectToStationSocket, server, stationName);
        channel = yield call(createStationSocketChannel, socket);
        // Let everyone know we're up and running
        yield put(nowPlayingSuccess(stationName));
        // Handle messages as they come in
        while (true) {
            let payload = yield take(channel);
            let songUpdate = JSON.parse(payload);
            let artist = songUpdate.song.display_artist;
            let title = songUpdate.song.title;
            let artUrl = null;
            if (songUpdate.song.image != null) {
                artUrl = `https://${server}${songUpdate.song.image}`;
            }
            yield put(nowPlayingUpdate(stationName, artist, title, artUrl));
        }
    } catch (error) {
        yield put(nowPlayingFailure(stationName, error));
    } finally {
        // Clean up the connection
        if (yield cancelled()) {
            if (typeof channel !== 'undefined') {
                channel.close();
            }
            if (typeof socket !== 'undefined') {
                socket.close();
            }
        } else {
            yield put(nowPlayingFailure(stationName, Error(`Web socket closed for ${stationName}.`)));
        }
    }
}

That’s not a lot of code to hook everything up, parse incoming messages and make dispatches to update the global store. Listening for events isn’t complicated either. We’ve just got to remember to ensure our Sagas are generator functions. That’s what the little asterisk after function indicates.

/**
 * Watches for launching the now playing saga.
 */
export function* watchNowPlaying() {
    yield all([
        takeEvery(STATION_LOAD_SUCCESS, nowPlayingSaga),
        takeEvery(NOW_PLAYING_FAIL, nowPlayingErrorSaga)
    ]);
}

Generator functions are a useful little tool that allows us to run a function in blocks, returning a value each time. Think along the lines of an interator. This becomes useful as a way of handing control back during asynchronous operations and is how I understand Sagas operate.

Unfortunately, it’s never always sunny. The latest issue I’m running in to is with custom middleware to log to Logstash during debugging. Every call is intercepted and pushes the data to the server in debugging mode (phone connected to my computer). However, this call fails to happen with the production binary. When I figure this one out, I’ll let you know as it’s stopping a bug from being fixed until I can get some real world behavioural data from the unreliable world of mobile networks.

As a tantalising treat, here’s the code I’ve got working in the test environment. Not the most secure (passwored in the store!) or robust but enough to get some logging going:

import axios from 'axios';
const logstashLogger = (store) => (next) => (action) => {
    // Get the current state
    const current = store.getState();
    // Pass the request down the line
    const result = next(action);
    // Get the new state
    const new_state = store.getState();
    // Put it all together and log it out if we're configured
    if ('logstash' in current) {
        const combined = {
            current: current,
            action: action,
            new: new_state
        };
        const url = `${current.logstash.url}${current.logstash.index}`;
        return axios.post(
            url,
            combined,
            {
                auth: {
                    username: current.logstash.username,
                    password: current.logstash.password
                }
            }
        ).then(function (response) {
            console.log(`Axios Logstash Response: ${response.data}`);
        }).catch(function (error) {
            console.log(`Axios Logstash Error: ${error}`);
        });
    } else {
        return result;
    }
}
export default logstashLogger

Any hints or suggestions most welcome.

You may also like...