Skip to content

Provide the default store implementations as classes #7193

@ponderingexistence

Description

@ponderingexistence

Describe the problem

Rationale:

As someone who has written quite a number of rather complex custom stores, two fundamental pain points have been the lack of polymorphism and lack of access to the store's current value in the custom store's implementation.

Because of this, I'm proposing that the default store implementation be provided as a class, precisely because this makes possible the two aforementioned scenarios.

Some real-world use cases wherein polymorphism and current value access would be essential to building certain custom stores are described below:

1. Access to the current value without subscription

This is a very common requirement. Imagine a timer function that returns a custom store that represents a countdown timer:

export function createTimer(shouldElapseMs: number, percisionMs = 50) {
    let state = {
        running: false,
        finished: false,
        remainingMs: shouldElapseMs,
        elapsedMs: 0,
    };

    const store = writable(state, () => stop);

    function start() {
        if (get(store).running)
            return;

        if (get(store).finished)
            reset();

        setInterval(() => {
            store.set({
                ...get(store),
                remainingMs: get(store).remainingMs - percisionMs,
                elapsedMs: shouldElapseMs - get(store).remainingMs,
                finished: get(store).remainingMs - percisionMs <= 0,
            });
            if (get(store).finished) stop();
        }, percisionMs);

        store.set({
            ...get(store),
            running: true,
        });
    }

    function stop() {
        // irrelevant...
    }

    function reset() {
        // irrelevant...
    }

    return {
        subscribe: store.subscribe,
        start,
        stop,
        reset,
    };
}

As you can see, we're using the get() function here (imported from svelte/store) each time we need the current value of the underlying store. However, the get() function is inefficient, as explained here in the docs:

This works by creating a subscription, reading the value, then unsubscribing. It's therefore not recommended in hot code paths.

So, in an example like the timer where we do have a "hot code path", (namely inside the setInterval callback, which is going to be executed every 50 milliseconds), this inefficiency is especially significant.

You might also think: "Well, we can't we just subscribe to the underlying store in our function?!"
Well, we can, but that would mean the underlying store will always have at least one subscriber, which in turn means that the function you return from the start callback, which only runs once when the last subscriber unsubscribes, will never be called. So this is certainly not a feasible solution either.

The writable function currently does store the current value as a local variable:

function set(new_value: T): void {
if (safe_not_equal(value, new_value)) {
value = new_value;

which of course means it doesn't expose it to the outside. If this, however, was a class, this variable could've been exposed as a protected property, which would give access to it to deriving classes, and that would beautifully solve this problem.

Note that various (ugly) workarounds like this are currently being used across the Svelte ecosystem, here's an example.

2. Polymorphism

Suppose you want to write a function that creates a writable store whose value is synchronized across all the open tabs (or browsing contexts, to be more accurate).

import { writable, type Writable, type StartStopNotifier } from 'svelte/store';

export function crossTabStore<T>(channelName: string, initValue: T, start?: StartStopNotifier<T>): Writable<T> {
    const underlyingStore = writable(initValue, start);

    const channel = new BroadcastChannel(channelName);
    channel.addEventListener('message', (e: MessageEvent<T>) => underlyingStore.set(e.data));

    return {
        ...underlyingStore,
        set(value) {
            underlyingStore.set(value);
            channel.postMessage(value);
        },
    };
}

This looks simple and straightforward. There is, however, a subtle bug in here: If the user of the returned store uses the update function to set a new value for the store, the value would NOT be synchronized. Why? Because our overridden custom set method, which in turn calls channel.postMessage(...) isn't called, even though the built-in update method does call set internally:

function update(fn: Updater<T>): void {
set(fn(value));
}

since there's no polymorphism when we're dealing with plain objects like this, it's actually the default set function that update is calling, and not the overridden/custom one.

This means we'd have to implement a very awkward custom update function too:

import { writable, type Writable, type StartStopNotifier } from 'svelte/store';

export function crossTabStore<T>(channelName: string, initValue: T, start?: StartStopNotifier<T>): Writable<T> {
    const underlyingStore = writable(initValue, start);

    const channel = new BroadcastChannel(channelName);
    channel.addEventListener('message', (e: MessageEvent<T>) => underlyingStore.set(e.data));

    return {
        subscribe: underlyingStore.subscribe,
        update(updater) {
            let newValue: T;
            underlyingStore.update(currentValue => {
                newValue = updater(currentValue);
                return newValue;
            });
            channel.postMessage(newValue);
        },
        set(value) {
            underlyingStore.set(value);
            channel.postMessage(value);
        },
    };
}

If, however, the default store implementation was a class that could be extended, we could've simply overridden the set function, and then whenever the update function calls set internally, polymorphism would kick in and everything would work as expected:

class CrossTabStore<T> extends Store<T> {
    private channel: BroadcastChannel;

    constructor(channelName: string, initialValue: T, start?: StartStopNotifier<T>) {
        super(initialValue, start);
        this.channel = new BroadcastChannel(channelName);
    }
    
    set(value) {
        super.set(value);
        this.channel.postMessage(value);
    }
}

Which one is cleaner? I reckon the difference is clear.

Describe the proposed solution

What I'd suggest we do is extract the default store implementation, which currently resides in the writable function here, into two classes (Store, and ReadonlyStore to differentiate them from the Writable and Readable interfaces, maybe? This is debatable though), and also keep the writable and readable functions, so as to avoid any breaking-change.

The writable function could still continue to be used whenever you want to simply create a store, and the new class, on the other hand, would be meant to be extended and used for creating custom stores.

And again, this change would not be breaking.

Regarding what would change in the tutorial, all I would change is the tutorial on custom stores, to start talking about these classes and how they are what should be used when creating custom stores.

Alternatives considered

As I mentioned, currently you have to utilize dirty workarounds to make up for these fundamental limitations, such as the ones I demonstrated above. So, this would indeed make creating custom stores much more efficient and developer-friendly.

Importance

would make my life easier

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions