Reactive library in ~60 lines of code

Aleks Onyshko - Feb 13 - - Dev Community

This article is heavily inspired by great article of Ryan Carniato

Feel free to check them out! My motivation for writing this article is to make these concepts even more understandable

@ryansolid and @mgechev are very intelligent and I had to spend a considerable amount of time to fully understand the solution. It is just ~60 lines of code and at first glance seems quite straightforward but when trying to comprehend what is really going on it appears very complex (subjectively for me).

This article will be about breaking down the solution listed in 2 articles above, so please check them out first.

Implementation starts

First, we have to define what we want. We will have two main entities Signal and Effect . Signal should store and return current value of a Signal. It should also trigger an Effect , if Effect used a Signal and Signal new value was set.

Let's try to represent this in the code.

type ReadableSignal<T> = () => T; // yes, indeed, signal is just a function that return value
Enter fullscreen mode Exit fullscreen mode

Then we can have a Signal which value we can change

interface WritableSignal<T> extends ReadableSignal<T> {
  set(value: T): void;
}
Enter fullscreen mode Exit fullscreen mode

Let's then create an actual code that will produce us these objects.

function signal<T>(value: T): WritableSignal<T> {
  const read = () => {
    return value;
  }

  // we use the fact that functions in js are just objects
  read.set = (nextValue: T) => {
      value = nextValue;
  };

  return read;
}
Enter fullscreen mode Exit fullscreen mode

Now can even can see how these Signal are working!

const count = signal(0);
console.log(count()); // 0

count.set(1);
console.log(count()); // now it is 2
Enter fullscreen mode Exit fullscreen mode

But by their own, Signal are not very useful. They are just now containers of value and with ability to change this value.
True power of Signal is felt, when someone can listen to changes in this Signal. They are event emitters after all.

Effect

So now just quick recap of what we want.

const count = signal(0);

effect(() => {
    console.log(count()); // executes initially
})

count.set(1); // effect is triggered and we will see 1 in the console
Enter fullscreen mode Exit fullscreen mode

First we will need something that represents and Effect

type Effect = () => void; // yes, effects are just functions that returns nothing
Enter fullscreen mode Exit fullscreen mode

And now we have to create effect function;

function effect(fn: Effect): void {
  const execute = () => {
    fn();
  }

  execute();
}
Enter fullscreen mode Exit fullscreen mode

This is not very useful yet, but this is a start.

How to let Signal and Effect know about each other?

We will need something like this.

//                 +---------------------+
//                 | Global Context [E]  |
//                 +---------------------+
//                       /          \
//                      /            \
//                     v              v
// +-----------------------+ <=====> +-------------------------------------------+
// |       Signal S        |         |         Effect E                          |
// |     value: X          |         |       execute()                           |
// | subscriptions: { E }  |         | dependencies: {S.subscriptions reference} |
// +-----------------------+         +-------------------------------------------+
//
Enter fullscreen mode Exit fullscreen mode

Let me try implement it in a code, so it will be a little bit more clear.

So the idea is that in GlobalContext we will have access to currenty executed Effect. Let's start be defining a type.

interface RunningEffect {
    execute: () => void; // a callback in the effect that should be invoked
    dependencies: Set<Set<RunningEffect>> // you can think of this as refs to signals 
}
Enter fullscreen mode Exit fullscreen mode

Let's create a context

const context: Running[] = [];
Enter fullscreen mode Exit fullscreen mode

Then we have to push this RunningEffect whenever an effect callback is executed(just a reminder it is executed every time, a Signal used inside of that Effect has reveived a new value).

It will look like this

function effect(fn: Effect): void {
    const execute = () => {
        context.push(running); // adding effect to context

        try {
            fn();
        } finally {
            context.pop(); // after completion of effect remove it from stack
        }
    }

    const running: Running = {
        execute,
        dependencies: new Set<Set<Running>>() // treat this Set<Signal> for simplicity
    }

    execute();
}
Enter fullscreen mode Exit fullscreen mode

Now we have to finish the puzzle in Signal

function signal<T>(value: T): WritableSignal<T> {
    // Set of Effects that listens to this signals
    const subscriptions: Set<Running> = new Set();

    const read = () => {
        const running = context[context.length - 1]; 
        // get currently executed effect. Keep in mind, 
        // that js is one-threaded,
        // so basically we have a guarantee 
        //that if we have an Effect in the context,
    // then this signal is being read from that Effect

    // if we have a running effect right now
        if(running) {
            // add effect to subscriptions of signal
            subscriptions.add(running); 

            // add a reference of subscriptions 
            //to the currently running effect
            running.dependencies.add(subscriptions);
        }

        return value;
    }

    read.set = (nextValue: T) => {
        value = nextValue;

        // triggering an Effect is signal value is changed
        for(let sub of [...subscriptions]) {
            sub.execute();
        }
    };

    return read;
}
Enter fullscreen mode Exit fullscreen mode

Now we have the whole picture

type ReadableSignal<T> = () => T;
interface WritableSignal<T> extends ReadableSignal<T> {
    set(value: T): void; 
}

type Effect = () => void;

interface Running {
    execute: () => void; // a callback in the effect that should be invoked
    dependencies: Set<Set<Running>> // Set of Effects that correspond to particular Signal
}

const context: Running[] = []; // context of running effects

function signal<T>(value: T): WritableSignal<T> {
    // Set of Effects that listens to this signals
    const subscriptions: Set<Running> = new Set();

    const read = () => {
        const running = context[context.length - 1]; 

    // if we have a running effect right now
        if(running) {
            // add effect to subscriptions of signal
            subscriptions.add(running); 

            // add a reference of subscriptions 
            //to the currently running effect
            running.dependencies.add(subscriptions);
        }

        return value;
    }

    read.set = (nextValue: T) => {
        value = nextValue;

        // triggering an Effect is signal value is changed
        for(let sub of [...subscriptions]) {
            sub.execute();
        }
    };

    return read;
}

function effect(fn: Effect): void {
    const execute = () => {
        context.push(running); // adding effect to context

        try {
            fn();
        } finally {
            context.pop(); // after completion of effect remove it from stack
        }
    }

    const running: Running = {
        execute,
        dependencies: new Set<Set<Running>>()
    }

    execute();
}
Enter fullscreen mode Exit fullscreen mode

This implementation is great!! But it is far too soon to clebrate. We have a problem.

Imagine we have this scenario. It is an Effect that reads Signals conditionally.

const count = signal(0);
const count2 = signal(666);

const condition = signal(true);

effect(() => {
    if(condition()) {
        console.log(count());
    } else {
        console.log(count2());
    }
})

// initial log of effect - 0, as condition() is true and count() is being read

condition.set(false);

// logs 666 as condition() is false

count.set(1);

// logs 666 again!!!! 
Enter fullscreen mode Exit fullscreen mode

We expect Effec to not trigger itself as count() should not be accessed.

We have to introduce some cleanup, so every time effect is executed, it's dependencies should be rebuild.

function cleanup(running: Running): void {
    for(const dep of running.dependencies) {
       // we delete Effect ref from every Signal, 
       // running.dependencies === Signal.subscriptions
        dep.delete(running);
    }

    running.dependencies.clear(); // we clear the effect from all dependencies
}
Enter fullscreen mode Exit fullscreen mode

Now we have to add it to the Effect lifecycle.

function effect(fn: Effect): void {
    const execute = () => {
        cleanup(running); // cleans up before every execution
        context.push(running);

        try {
            fn();
        } finally {
            context.pop();
        }
    }

    const running: Running = {
        execute,
        dependencies: new Set<Set<Running>>()
    }

    execute();
}
Enter fullscreen mode Exit fullscreen mode

That's it!! With this adaptions our problem is solved!

const count = signal(0);
const count2 = signal(666);

const condition = signal(true);

effect(() => {
    if(condition()) {
        console.log(count());
    } else {
        console.log(count2());
    }
})

// initial log of effect - 0, as condition() is true and count() is being read

condition.set(false);
// logs 666 as condition() is false

count.set(1);
// nothing is being logged!!!
Enter fullscreen mode Exit fullscreen mode

Here is the full code with the diagram

type ReadableSignal<T> = () => T;
interface WritableSignal<T> extends ReadableSignal<T> {
    set(value: T): void;
}

type Effect = () => void;

interface Running {
    execute: () => void;
    dependencies: Set<Set<Running>>
}

const context: Running[] = [];

function signal<T>(value: T): WritableSignal<T> {
    const subscriptions: Set<Running> = new Set();

    const read = () => {
        const running = context[context.length - 1];

        if(running) {
            subscriptions.add(running);
            running.dependencies.add(subscriptions);
        }

        return value;
    }

    read.set = (nextValue: T) => {
        value = nextValue;

        for(let sub of [...subscriptions]) {
            sub.execute();
        }
    };

    return read;
}

function cleanup(running: Running): void {
    for(const dep of running.dependencies) {
        dep.delete(running);
    }

    running.dependencies.clear();
}

function effect(fn: Effect): void {
    const execute = () => {
        cleanup(running);
        context.push(running);

        try {
            fn();
        } finally {
            context.pop();
        }
    }

    const running: Running = {
        execute,
        dependencies: new Set<Set<Running>>()
    }

    execute();
}

//                 +---------------------+
//                 | Global Context [E]  |
//                 +---------------------+
//                       /          \
//                      /            \
//                     v             v
// +-----------------------+ <=====> +-------------------------------------------+
// |       Signal S        |         |         Effect E                          |
// |     value: X          |         |       execute()                           |
// | subscriptions: { E }  |         | dependencies: {S.subscriptions reference} |
// +-----------------------+         +-------------------------------------------+
//
Enter fullscreen mode Exit fullscreen mode

Wish you happy coding. If anything is unclear please don't hesitate to comment.

. . . .