How to Stop Using Callbacks and Start Living

July 31, 2018 0 Comments

How to Stop Using Callbacks and Start Living

 

 

Javascript has two major ways of dealing with asynchronous tasks - callbacks and Promises. In general Promises are considered easier to use and to maintain than callbacks. But in reality even Promises alone won’t make you happy. Asynchronous code may still be quite difficult to read and to understand. Therefore third-party libraries, e.g. co, provided means to write a synchronous-like asynchronous code.

I personally prefer everything in the world to be as clear and beautiful as redux-saga. But not everybody is lucky to work with React and Redux to be able to use sagas. In this article I will show that in modern Javascript it is not difficult to write a well structured and easy to understand asynchronous code without using any third-party libraries.

Callback hell

Let’s start with an example. Say, we have an object that can read some data from a stream and this object uses an event emitter to notify everyone interested of the events. The events are ‘start’, ‘data’, ‘stop’ and, to make things a bit more complicated, ‘pause’.

So we want to catch the ‘start’ event on which we would like start getting and storing data while listening to ‘data’ event. And on ‘stop’ event we need to perform some data processing. On ‘pause’ event we stop waiting for the next ‘data’ event and wait for ‘start‘ instead to continue getting and storing data.

Here is the code:

let data = ''; const handleStart = () => { streamReader.removeAllListeners('pause', handlePause); streamReader.on('data', (chunk, err) => { if (err) { console.error(err); streamReader.removeAllListeners('data'); streamReader.removeAllListeners('pause'); return; } data += chunk; }) } const handleStop = () => { streamReader.removeAllListeners('data'); streamReader.removeAllListeners('pause'); streamReader.removeAllListeners('stop'); processData(data, (err, result) => { if (err) { console.error(err); return; } storeResult(result, () => { console.log('Stored') }) }); } const handlePause = () => { streamReader.removeAllListeners('data'); streamReader.on('start', handleStart); } streamReader.once('start', handleStart); streamReader.on('stop', handleStop) streamReader.on('pause', handlePause); 

Here we have a bunch of event listeners and event handlers that implement the flow described above. Also there are some functions called processData and storeData which perform some asynchronous actions and call a callback when finished.

What is wrong with this code? Well… I think, it’s a complete nightmare. First of all, there is a global variable data impossible to get rid of. Also I mentioned a flow above, but there is no flow in the code. It is very difficult to comprehend the sequence of actions and therefore it is very difficult to debug. People don’t call it ‘callback hell’ for nothing.

Way out

The good thing about the asynchronous callbacks in Javascript is that you don’t have to ever use them if you don’t want to. Any callback may be turned into a Promise. The simplest example would look like this:

const processDataPromise = new Promise((resolve, reject) => { processData(data, (err, result) => { if (err) reject(err); resolve(result); }); }) 

Or a more general solution:

function promisify(f, context, isEvent) { const ctx = context || this; return function () { return new Promise((resolve, reject) => { f.call(ctx, ...arguments, (...args) => { const err = arguments ? args.find((a) => a instanceof Error) : null; if (err) { reject(err); } else { if (isEvent) { resolve({ type: arguments[0], cbArgs: [...args], }); } else { resolve([...args]); } } }) }); } } 

The general solution is not immediately clear off course, so let me explain.

The function promisify takes an asynchronous function as the first argument and returns a function that takes all the same parameters as the original one except for the callback. When this returned function is called, it returns a Promise. The original function is called inside the Promise which is resolved when the original function calls the callback. If the original function has a context (the context argument of promisify), it is bound to it when called inside the Promise. If the original function is just an ordinary asynchronous function, we resolve the Promise with the callback`s arguments. If it is an event listener (isEvent = true), we return both the event type and the callback arguments. And if the callback is called with and error, the Promise gets rejected.

The application of promisify looks like this:

const processDataPromise = promisify(processData); const storeResultPromise = promisify(storeResult); const onEventPromise = promisify(emitter.once, emitter, true); 

And the Promise may be used this way:

processDataPromise(data).then(([err, processedData]) => { /* do something with the data*/ }) 

But there is a better way.

Saga-like heaven

And we do need a better way here, because it is pretty much impossible to squash a flow like the one described above into a Promise chain.

The better way is a Javascript async function and here is another implementation of the same flow:

async function readStream(streamReader, initialData) { const processDataPromise = promisify(processData); const storeResultPromise = promisify(storeResult); const onEventPromise = promisify(streamReader.once, streamReader, true); await onEventPromise('start'); let data = initialData || ''; while (true) { try { const event = await Promise.race([ onEventPromise('data'), onEventPromise('stop'), onEventPromise('pause'), ]); const {type} = event; if (type === 'data') { const [chunk] = event.cbArgs; data += chunk; } if (type === 'pause') { readStream(streamReader, data); break; } if (type === 'stop') { const [err, processedData] = await processDataPromise(data); await storeResultPromise(processedData); return processedData; } } catch (err) { handleError(err); return; } } } 

Well, what is good about this implementation? To start with, it just looks prettier. Secondly and more importantly, there is a flow in this code. It is almost like flowchart where you can trace the whole sequence with every loop and every branch step by step. It does look like a saga I mentioned in the beginning, but there is no need to know anything about redux-saga to write a code like this. More importantly, it is a code you can live with.

More tutorials in this series:

Missing Part of Redux Saga Experience

Using Normalizr to Organize Data in Stores – Practical Guide

Usage of Reselect in a React-Redux Application

Using Normalizr to Organize Data in Store. Part 2


Tag cloud