AASHAN

Fine Grained Reactivity - Implementing a reactive frontend from scratch

Wed Jun 28 2023 · 7 min read
Fine Grained Reactivity - Implementing a reactive frontend from scratch

Preface

If you have ever worked with frontend libraries, you might have seen code like this


const [counter, setCounter] = useState(0);

useEffect(() => {
    console.log(`Current count: ${counter}`);
}, [counter]);

setCounter(counter + 1);

or


const [counter, setCounter] = createSignal(0);

createEffect(() => {
    console.log(`Current count: ${counter()}`);
});

setCounter(counter() + 1);

or even


let counter = 0;

$: {
    console.log(`Current count: ${counter}`);
}

counter += 1;

Have you ever wondered how these statements work under the hood? Well, the simplest answer to this question would be a concept called reactivity.

Almost every frontend library since the days of Angular 1 and Knockout have been implementing reactivity in some way. It has been an integral part of the modern frontend stack and we can't see frontend development possible without some kind of reactive pattern being implemented into our projects. It has made our lives so much easier and nightmarish hell at the same time (I am looking at you react dependency arrays).

Anyway, in this blog, we are going to explore the basics of reactivity, fine grained reactivity and try to implement our own fine grained reactivity system (and possibly create a next multi-million dollar javascript frontend library that is backed by a MANGA company).

What is Reactivity?

To better understand reactivity, let us consider this example:

let a = 12;
let b = 14;

let c = a + b;
b = 16;

Now, at the end of this program, what do you think will be the value of the variable c? 26 right? We have done this a million times already.

Now, let's consider a different scenario.


let first_name = "Aashan";
let last_name = "Ghimire";

let full_name = `${first_name} ${last_name}`;

first_name = "John";

Here, we are trying to compute full name from first name and last name. But if either of the first name or last name changes, we need to re-compute full name in order to get the latest value.

But what if you didn't have to do it? What if each time first_name or last_name changed, your full_name would be magically updated and you didn't have to worry about changes?

This is where reactivity comes in. Reactive data structures (or signals as they are referred to in fine-grained reactivity) give us the capability to automatically propagate the changes applied to itself to the places where it depends on.

Now each time you look into a react effects and dependency array or solid effects or computed values in vue and svelte, you know that those things are handling reactive values.

In the simplest of terms, a value is said to be reactive if you are able to subscribe to its changes. Now that you have basic idea about reactivity, let's write a javascript library.

memes-risitas.gif

The createSignal function

For all react nerds out there, createSignal is going to be our useState. It is going to hold the actual data for us and provide us with the getter and setter. But, there will be a key difference between createSignal and useState. It will be the getter of the underlying saved state value. In react, we would do something like

const [counter, setCounter] = useState(0);

and when we need to access the value of counter, we just use the counter.

But, with createSignal, the equivalent statement will be

const [counter, setCounter] = createSignal(0);

and to use the actual counter, we would need to actually call the getter function like counter(). Notice the parenthesis.

Now, we can assume the function definition will be something like this

function createSignal<T> (value: T) 
{
    let data = value;
    
    const getter = (): T => {
        return data;
    }
    
    const setter = (value: T) => {
        data = value;
    }

    return [getter, setter]
}

Now that we prepared the basic structure for createSignal, we need to tackle more challenging part of exposing the changes to the subscribers.

So, in theory how it'd work is each signal will store its subscribers along with the actual value (or the state). Now, each time the getter function is called, a subscriber is added into it's list of subscribers and each time the setter function is called, all of it's subscribers are called sequentially. We also will need to keep track of the active or the currently running subscribers to different signals. We need the running subscribers in order to communicate with the effects which we will look into later.

So, first things first, let's create the subscriber type.

type Subscriber<T> = {
    dependencies: Set<Set<Subscriber<T>>>,
    execute: () => T;
};

const context: Array<Subscriber<any>> = [];

You might be confused on why the type of dependencies is Set<Set<Subscriber<T>>>, but it's pretty simple. A subscriber can have multiple dependencies and a signal can have multiple subscribers subscribing the changes. If a subscriber is represented by Set<Subscriber<T>>, multiple subscribers are represented by Set<Set<Subscriber<T>>>. In retrospective, it might have been better to call this property dependents, but here we are, bad at naming things.

The context variable will hold the currently active set of effects as discussed previously.

Now, we need to create a subscribe method that adds the subscriptions to the list of dependencies. This is pretty straight forward to I am not going to spend much time talking about it. Here is the code.

function subscribe<T>(subscription: Subscriber<T>, subscriptions: Set<Subscriber<T>>) {
    subscriptions.add(subscription);
    running.dependencies.add(subscriptions);
}

Now, the next step is we need to somehow add subscribers into the context when we call the getter and also call all the subscribers when we call the setter methods. So the createSignal method becomes something like

function createSignal<T>(value: T): [() => T, (value: T) => void] {
    let data = value;
    let subscriptions: Set<Subscriber<T>> = new Set();

    const get = () => {
        const running = context[context.length - 1];
        if (running) {
            subscribe(running, subscriptions);
        }

        return data;
    };
    const set = (value: T) => {
        data = value;

        for (const subscription of [...subscriptions]) {
            subscription.execute();
        }
    }
    return [get, set];
}

The createEffect function

The idea of createEffect is similar to that of useEffect in react, except we are not going to explicitly define our dependencies. The createEffect function will do that for us along with the createSignal function that we created earlier. So, each time a signal which is called inside an effect changes, the effect executes.

Here is a basic implementation of createEffect


function createEffect<T>(effect: () => T): T {
    const execute = () => {
        cleanup(subscriber); // to be implemented later
        context.push(subscriber);
        
        try {
            effect();
        } finally {
            context.pop();
        }
    }
    
    const subscriber: Subscriber<T> = {
        dependencies: new Set(),
        execute
    };
    
    return execute();
}

Here, we are using the variable hoisting provided by javascript runtime to first use the variable inside the execute function and later declare the subscriber. The cleanup function is responsible for cleaning up the dependencies before we create any new dependency. It can be something like this


function cleanup<T>(subscriber: Subscriber<T>) {
    for (const dep of subscriber.dependencies) {
        dep.delete(subscriber);
    }
    subscriber.dependencies.clear();
}

The cleanup function clears the current subscriber from the list of dependencies of all the subscribers and also clears its own dependencies.

Using the createSignal and createEffect methods.

Now that we have created signal and effects, we can utilize reactivity. Let's write some reactive code now.

const [firstName, setFirstName] = createSignal("John");
const [lastName, setLastName] = createSignal("Doe");

const fullName = () => {
    return `${firstName()} ${lastName()}`;
}

const firstNameInput = document.createElement("input");
firstNameInput.value = firstName();
const lastNameInput = document.createElement("input");
lastNameInput.value = lastName();

document.body.appendChild(firstNameInput);
document.body.appendChild(lastNameInput);

firstNameInput.addEventListener("input", (e) => {
    setFirstName((e.target as HTMLInputElement).value);
});

lastNameInput.addEventListener("input", (e) => {
    setLastName((e.target as HTMLInputElement).value);
});

const fullNameDisplay = document.createElement("h2");
document.body.appendChild(fullNameDisplay);

createEffect(() => {
    fullNameDisplay.innerText = fullName();
});

Try running this and you'll find that every time you change firstName or lastName, your fullName is automatically updated.

steve-carrell.webp

If you would like to see the whole source code, it is available in github. I also have implemented createMemo which is equivalent of useMemo in react. Go through the source code if you find this interesting. https://github.com/aashan10/fine-grained-reactivity

Thanks for following till the end. I hope you learnt something new today.

Have some questions? Let's get in touch

Related posts