DEV Community

Ember
Ember

Posted on

How signals really work in JavaScript? Basic concept.

Hi there! If you've built frontend application, you've obviously faced the problem of the state management: initializing, editing, computing states and reacting to its changes. There are a huge count of solutions for it. For example, React has useState/useReducer with a lot of libraries, Vue - ref and reactive, Angular and Solid - signals. Guess what am I going to talk about? Of course, about the last one!

Signals is getting popular last time. Why?

  • Signals are predictable. They do exactly what you want them to do!
  • Signals are simple. They give you an intuitive API: getters, setters, computeds (calculate value based on another one) and effects (react to value's changing).
  • Signals are magicable - the predictable behaviour and simple API make you really happy and give a sense, which can be described as "How does this happen?".

I would just describe the mechanism of signals in a tedious and boring way. Although there's absolutely nothing that difficult to understand it. Instead, I'll show you how to built signals from scratch. You need only to read and experiment with the code. Let's go!

Getting started

TL;DR

// There will be three functions: createSignal, createEffect and computed

// initializing a new signal
const signal = createSignal(0);

// getting value
const currentValue = signal.value;

// setting value
signal.value = 5;

// watching for the `signal` changes
createEffect(() => `Signal has changed to ${signal.value}`)
createEffect((prev) => `Another effect: signal has changed from ${prev} to ${signal.value}`)

// making a computed signal
const doubledSignal = computed(() => signal.value * 2);
doubledSignal.value = 10; // TypeScript error, because `doubledSignal` is readonly;
Enter fullscreen mode Exit fullscreen mode

In first, let's define, what interface exactly we want to get, because all realizations are different from each other. Solid.JS's createSignal returns an array of two functions: get and set:

// example.ts

const [getValue, setValue] = createSignal(0);

// getting value
const currentValue = getValue();

// setting value
seValue(5);
Enter fullscreen mode Exit fullscreen mode

This approach is powerful and I really like it. It can even let you provide a function setter in the set function. See: https://docs.solidjs.com/reference/basic-reactivity/create-signal

But, for educational purpose and keeping things quite simple for you, I'd prefer another one: instead of an array of two functions, our createSignal will return an object with getter and setter value, like in Vue or Preact:

const signal = createSignal(0);

// getting value
const currentValue = signal.value;

// setting value
signal.value = 5;
Enter fullscreen mode Exit fullscreen mode

Also, there will be createEffect and computed. In createEffect we pass a callback with optional prevValue param (the value which signal had before update) which calls every time when signal's values changes. computed gets the same callback, as createEffect, but returns readonly signal.

createEffect(() => `Signal has changed to ${signal.value}`)
createEffect((prev) => `Another effect: signal has changed from ${prev} to ${signal.value}`)

const doubledSignal = computed(() => signal.value * 2);
doubledSignal.value = 10; // TypeScript error, because `doubledSignal` is readonly
Enter fullscreen mode Exit fullscreen mode

Building a Signal

Let's define the ISignal interface:

// types.ts

export interface ISignal<T> {
    value: T;
}
Enter fullscreen mode Exit fullscreen mode

It takes the T generic - type of signal's value. Nothing hard yet.
The Signal class itself:

// signal.ts

import type { ISignal } from './types';

class Signal<T> implements ISignal<T> {
    constructor(public value: T) {}
}

// The same as:
class Signal<T> implements ISignal<T> {
    public value: T;

    constructor(value: T) {
        this.value = value;
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, most of you will stop and ask - "How to watch for value's changes by createEffect?". The solution may be not that obvious as you want it to be, but, trust me, it's really easy. Follow these steps:

1. Pub/Sub pattern

In order to be able to watching for value, the signal instance should have its subscribers. Mostly, they are functions which call every time when something happens and some event is emitted. In our case, they should react, when the signal's value is changed. Let's make this!

// types.ts
// this is a type of a subscriber
export type Effect<T = any>  = (prevValue?: T) => void;

// signal.ts
import type { Effect, ISignal } from "./types";

class Signal<T> implements ISignal<T> {
    public subscribers: Set<Effect<T>>;

    constructor(public value: T) {
        this.subscribers = new Set();
    }

    /**
     * Adds a subscriber to the signal.
     * @param cb The effect to subscribe.
     */
    subscribe(cb: Effect<T>) {
        this.subscribers.add(cb);
    }

    /**
     * Notifies all subscribers about the value change.
     * @param prev The previous value of the signal.
     */
    emit(prev?: T) {
        this.subscribers.forEach((cb) => cb(T));
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Getter and setter

The code above will not work as you would expected - all subscribers will not be called until you explicitly call emit. But it's wanted to call it automatically, when the value changes. How to do that? Simply! Let's define getter and setter for value and make the source value private.

class Signal<T> implements ISignal<T> {
    constructor(private _value: T) {
        // Nothing changes
    }

    // Nothing changes

    get value(): T {
        return this._value;
    }

    set value(newValue: T) {
        this._value = newValue;
    }
}
Enter fullscreen mode Exit fullscreen mode

And call emit in the setter.

class Signal<T> implements ISignal<T> {
    // Nothing changes

    set value(newValue: T) {
        const prevValue = this._value;

        this._value = newValue;
        this.emit(prevValue);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we can do this easily:

// index.ts

import { Signal } from "./signal";

const signal = new Signal(0);

signal.subscribe(() => {
    console.log("Signal changed to:", signal.value);
});
signal.value = 42; // Signal changed to: 42
signal.value = 100; // Signal changed to: 100
Enter fullscreen mode Exit fullscreen mode

But, this is not what we expected. There's no signal's magic, no createEffect. Don't be upset! We need to make one big step.

3. SignalFacade

I'm going to make another class - SignalFacade. It will help us centralize all methods and its data.

// types.ts

/**
 * Facade interface for managing signals and effects.
 */
export interface ISignalFacade {
    /**
     * Effect that is currently being executed.
     */
    activeEffect: Effect | null;

    /**
     * Creates a new signal.
     * @param initialValue The initial value of the signal.
     *
     * @returns The created signal.
     */
    createSignal<T>(initialValue: T): ISignal<T>;

    /**
     * Creates a new effect.
     * @param cb The effect callback.
     */
    createEffect<T>(cb: Effect<T>): void;

    /**
     * Creates a computed signal.
     * @param cb The computation callback.
     *
     * @returns A readonly signal representing the computed value.
     */
    computed<T>(cb: () => T): Readonly<ISignal<T>>;
}
Enter fullscreen mode Exit fullscreen mode

First - createSignal. It's just a factory, which returns a new Signal instance.

// signalFacade.ts

class SignalFacade implements ISignalFacade {
    public createSignal<T>(initialValue: T): ISignal<T> {
        const signal = new Signal(initialValue);

        return signal;
    }
}
Enter fullscreen mode Exit fullscreen mode

Second - createEffect. The most intriguing and confusing method for us. Let's start from the beginning. In first, you just need to call the provided callback.

// signalFacade.ts
class SignalFacade implements ISignalFacade {
    public createEffect<T>(cb: Effect<T>) {
        cb();
    }
}
Enter fullscreen mode Exit fullscreen mode

Still no magic. How the signal would know, which callback use its value? That's where the most interesting things start happening. Declare a new property - activeEffect -, like in the interface. The initial value is null.

// signalFacade.ts
class SignalFacade implements ISignalFacade {
    public activeEffect: Effect | null = null;
}
Enter fullscreen mode Exit fullscreen mode

Then set the passed callback to this property. After the callback is called, reset the property.

// signalFacade.ts
class SignalFacade implements ISignalFacade {
    public createEffect<T>(cb: Effect<T>) {
        this.activeEffect = cb;
        cb();
        this.activeEffect = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, change the Signal's constructor by adding new parameter - facade. This will make it able to pass SignalFacade's this context to a signal's instance.

// signal.ts
import type { ISignalFacade } from './types';

class Signal<T> implements ISignal<T> {
    constructor(private readonly facade: ISignalFacade, initialValue: T) {
        // Nothing changes
    }
}

// signalFacade.ts
class SignalFacade implements ISignalFacade {
    public activeEffect: Effect | null = null;

    public createSignal<T>(initialValue: T): ISignal<T> {
        const signal = new Signal(this, initialValue);

        return signal
    }

    public createEffect<T>(cb: Effect<T>) {
        this.activeEffect = cb;
        cb();
        this.activeEffect = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The last trick - in the signal's value's getter add these lines:

// signal.ts
class Signal<T> implements ISignal<T> {
    get value(): T {
        if (
            this.facade.activeEffect &&
            !this.subscribers.has(this.facade.activeEffect)
        ) {
            this.subscribe(this.facade.activeEffect);
        }

        return this._value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the magic with createEffect will work as expected. You can check it out. YAAAAAAAY!

What exactly does happen?

SignalFacade provides its context to each Signal instance, making it avaible to access all its public properties. When createEffect is called, it marks the passed callback active (sets it to the SignalFacade's activeEffect property) and calls it. Called callback triggers value's getter, which checks whether activeEffect is set and doesn't subscribed to this signal, and then just subscribe the active callback. Next time, when the signal's value gets updated, the signal notifies all subscribers and calls them. That's all magic.

After creating createSignal and createEffect, there's one more step to complete this challenge - computed. After createEffect, there's no more harder, than it. You need only 5 lines of code! Just imagine computed like a signal with an effect and you get this:

// signalFacade.ts
class SignalFacade implements ISignalFacade {
    public computed<T>(cb: () => T): Readonly<ISignal<T>> {
        const signal = this.createSignal<T>(undefined as T);

        this.createEffect(() => {
            signal.value = cb();
        });

        return signal;
    }
}
Enter fullscreen mode Exit fullscreen mode

Why do I use undefined as default value? Because the initial value of the signal should be empty. This is why I didn't use cb() as the initial value either. But, also this means calling the callback twice, which is not good. Why a computed signal is readonly? Because it's intended to update its value itself based on another signal's value and you should not modify it by yourself.

4. Create functions

You just need to make a simple functions, which just calls all three methods.

// index.ts

import { SignalFacade } from './signalFacade';

const signalFacade = new SignalFacade();

function createSignal<T>(initialValue: T) {
    return signalFacade.createSignal(initialValue);
}

function createEffect<T>(cb: Effect<T>) {
    return signalFacade.createEffect(cb);
}

function computed<T>(cb: () => T) {
    return signalFacade.computed(cb);
}
Enter fullscreen mode Exit fullscreen mode
// index.ts
const signal = createSignal(10);

const doubled = computed(() => {
    return signal.value * 2
});

createEffect(() => {
    console.log('Signal changed to', doubled.value);
})

console.log(signal.value, doubled.value); // 10 20

signal.value = 20;

console.log(signal.value, doubled.value); // 20 40
Enter fullscreen mode Exit fullscreen mode

And that's finally done. Congratulations!

Conclusion

We discovered how signals work and write theirs simple realization. This is not the best one, of course. You can do better!

Thanks for reading! If this arcticle helped you, like it and leave your comments!

GitHub
Telegram
My previous article - Beyond Enums and Arrays: Why Bitwise Flags Are Your Next TypeScript Tool

Top comments (0)