Coroutines in Javascript

First publication date:

A coroutine is a function whose execution can be suspended and resumed, possibly passing some data. They happen to be useful for implementing various patterns involving cooperation between different tasks/functions such as asynchronous flows for example.

In javascript

In Javascript you can implement (sort of) coroutines using generator functions. You may have already used generator functions to implement iterators and sequences.

function *integers(){
    let n = 0;
    while(true){
        yield ++n;
    }
}

const sequence = integers();

console.log(sequence.next().value); // > 1
console.log(sequence.next().value); // > 2
console.log(sequence.next().value); // > 3
console.log(sequence.next().value); // > 4

The while(true) is interesting (and totally fine) here because it testifies that the generator is being evaluated lazily. What actually happens when you call the next function is that the generator is executed until the next yield statement. Whatever the result of the expression on the right side of the yield is, it becomes the value of the iterator result and the generator function is paused.

What we don’t usually know is that you can pass data to the next function when you resume the execution of the routine, which has the effect of assigning that data to any variable on the “left” side of the statement:

function *generator() {
    while(true){
        const action = yield;
        console.log(action)
    }
}

const routine = generator();
routine.next();
routine.next('increment'); // > 'increment'
routine.next('go-left'); // > 'go-left

The first call to next obviously cannot receive any data as the routine has not been paused yet.

Bidirectional example

Although you will often use the generator as either a producer or a sink of data, you can use it in both directions at the same time. Beware, it can be confusing and complex to manage, but it comes in handy to implement some patterns.

See the following “Redux” like state machine:

function* EventLoop({reducer, state}) {
    while (true) {
        const action = yield state; // wow !
        state = reducer(state, action);
    }
}

const createEventLoop = ({reducer, state}) => {
    const eventLoop = EventLoop({reducer, state});
    eventLoop.next();
    return (action) => eventLoop.next(action).value;
};

const createSubscribable = () => {
    const eventName = 'state-changed';
    const eventTarget = new EventTarget();
    
    const notify = () => eventTarget.dispatchEvent(new CustomEvent(eventName));
    const subscribe = (listener) => {
        eventTarget.addEventListener(eventName, listener);
        return () => unsubscribe(listener);
    };
    const unsubscribe = (listener) =>
        eventTarget.removeEventListener(eventName, listener);

    return {
        unsubscribe,
        subscribe,
        notify
    };
};

const createStore = ({reducer, initialState}) => {
    let state = initialState;

    const {notify, ...subscribable} = createSubscribable();

    const dispatch = createEventLoop({reducer, state});

    return {
        ...subscribable,
        getState() {
            return structuredClone(state);
        },
        dispatch(action) {
            state = dispatch(action);
            notify();
        }
    };
};

const store = createStore(
    {
        reducer: (state, action) => {
            switch (action.type) {
                case 'increment':
                    return {
                        ...state,
                        count: state.count + 1,
                    };
                case 'decrement':
                    return {
                        ...state,
                        count: state.count - 1,
                    };
                default:
                    return state;
            }
        },
        initialState: {
            count: 0,
        }
    }
);

store.subscribe(() => console.log(store.getState()));

store.dispatch({
    type: 'increment'
}); // log { count: 1 }
store.dispatch({
    type: 'increment'
}); // log { count: 2 }
store.dispatch({
    type: 'decrement'
}); // log { count: 1 }

The interesting part for us is the EventLoop routine which, when paused, yields the current state and, when resumed, receives the next action to process. The createEventLoop function hides the fact that we are using a coroutine to implement the state machine, making it a detail of the implementation. However, thanks to the coroutine, the overall solution remains concise and quite simple.

Async flow example

In the previous example we saw how we could model an event loop with a coroutine. In the following example, we will see a different kind of “cooperative multitasking”, building an asynchronous workflow with the same semantics as the regular async function ( with the await keyword).

const co = (genFn) => (...args) => {
  const gen = genFn(...args);
    
  // no data to next as the routine has not been paused yet
  return next();

  function next(data) {
    const { value, done } = gen.next(data);

    if (done) {
      return value;
    }

    // non promise value
    if (value?.then === undefined) {
      return next(value);
    }

    // we resume the routine assigning the resolved value to "yield"  
    return value.then(next);
  }

};

const fn = co(function* (arg) {
  let value = yield asyncTask(arg);
  value = yield otherAsyncTask(value);
  return value;
});

fn(42).then(console.log);

The idea behind is quite simple: our main asynchronous function is paused whenever it delegates a task to another function. If that function is itself asynchronous, we wait for the pending Promise to resolve and then resume the main routine with the resolved value. This is very similar to the async function, except that you replace the built-in await keyword with yield.

Going further

It is important to note that a generator has more than just the next function. return and throw can indeed help to create different flows. In , we will see how we can use a coroutine to model a UI component as an event loop, where each iteration represents a content rendering.