Skip to content
Advertisement

Why does a derived Svelte store have different behaviour when using `$` vs `subscribe`

I have a data model that I can’t change in this project. I’m trying to strip down and simplify the sample code below, so hopefully this still makes sense with what I’m trying to re-produce.

Let’s say I have two stores. One store holds “containers” and the other store holds “items” – each store is independently used throughout the app for various purposes. The Container only holds Item IDs by default. Occasionally, I want to de-normalize the data for use on some pages. I use a derived store and denormalize each of the container objects, turning them into DenormalizedContainers.

Let’s say I have a use case where I want to create a new Item object, and then add it to a given Container. As far as I can tell, that will cause the derived store to update twice (once for the change to items and again for a change to containers), and thus anything that uses that derived store will update twice (depending on how it’s called).

Why would the behaviour be different from subscribe to $?

Also, while this isn’t a huge problem for me, I’m just curious if there is any native Svelte-y way to workaround this without changing the data model (while still being able to use the subscribe API?

<script lang="ts">
    import { derived, writable } from "svelte/store";

    type Container = {
        id: string;
        itemIds: string[];
    };

    type Item = {
        id: string;
        name: string;
    };

    type DenormalizedContainer = {
        id: string;
        items: Item[];
    };

    const containers = writable<Container[]>([]);
    const items = writable<Item[]>([]);

    const denormalizedContainers = derived(
        [containers, items],
        ([$containers, $items]): DenormalizedContainer[] => {
            return $containers.map(({ id, itemIds }) => {
                const denormalizedContainer = {
                    id,
                    items: itemIds.map((id) =>
                        $items.find((item) => {
                            item.id === id;
                        })
                    ),
                };
                return denormalizedContainer;
            });
        }
    );

    function handleAddContainer() {
        const value = Math.random() * 1000;
        const newContainer: Container = { id: `new-container-${value}`, itemIds: [] };
        containers.set([...$containers, newContainer]);
    }
    function handleAddItem() {
        const value = Math.random() * 1000;
        const newItem: Item = { id: `new-id-${value}`, name: `new-name-${value}` };
        items.set([...$items, newItem]);
    }
    function handleAddBoth() {
        const value = Math.random() * 1000;
        const newItem: Item = { id: `new-id-${value}`, name: `new-name-${value}` };
        const newContainer: Container = { id: `new-container-${value}`, itemIds: [newItem.id] };
        items.set([...$items, newItem]);
        containers.set([...$containers, newContainer]);
    }

    $: console.log(`$: There are ${$containers.length} containers`);
    $: console.log(`$: There are ${$items.length} items`);
    $: console.log(`$: There are ${$denormalizedContainers.length} denormalized containers`);

    denormalizedContainers.subscribe((newValue) =>
        console.log(
            `Subscribe: There are ${$denormalizedContainers.length} denormalized containers`
        )
    );
</script>

<button class="block" on:click={() => handleAddContainer()}>Add container</button>
<button class="block" on:click={() => handleAddItem()}>Add item</button>
<button class="block" on:click={() => handleAddBoth()}>Add container and item</button>

Results:

Upon clicking each button once, here are the logs:

Page load:

Subscribe: There are 0 denormalized containers
$: There are 0 containers
$: There are 0 items
$: There are 0 denormalized containers

Add container:

Subscribe: There are 1 denormalized containers
$: There are 1 containers
$: There are 1 denormalized containers

Add item:

Subscribe: There are 1 denormalized containers
$: There are 1 items
$: There are 1 denormalized containers

Add both:

Subscribe: There are 1 denormalized containers
Subscribe: There are 2 denormalized containers
$: There are 2 containers
$: There are 2 items
$: There are 2 denormalized containers

The updates handled via $ are what I would like all the time, but for some reason the manual subscribe API shows two updates. I have a couple locations outside of .svelte files, where I would need to use the subscribe API.

References:

Similar to this, but doesn’t explain why the $ behaves differently to the subscribe.

Svelte Derived Store atomic / debounced updates

Possibly the underlying problem?

https://github.com/sveltejs/svelte/issues/6730

Possibly explained in 3.1/3.2 here. Micro-tasks bundle up reactive statements and apply them at the end of the tick. Whereas subscribe handles updates in real-time. Note: Wrapping my subscribe code in a tick just makes it run twice with the same data.

https://dev.to/isaachagoel/svelte-reactivity-gotchas-solutions-if-you-re-using-svelte-in-production-you-should-read-this-3oj3

Advertisement

Answer

If you look at the compiled output you can see that the reactive statements are triggered on update, which should only happen once per event loop.

    $$self.$$.update = () => {
        if ($$self.$$.dirty & /*$containers*/ 256) {
            $: console.log(`$: There are ${$containers.length} containers`);
        }

        if ($$self.$$.dirty & /*$items*/ 128) {
            $: console.log(`$: There are ${$items.length} items`);
        }

        if ($$self.$$.dirty & /*$denormalizedContainers*/ 64) {
            $: console.log(`$: There are ${$denormalizedContainers.length} denormalized containers`);
        }
    };

You can create your own method of bundling updates in an event loop. E.g. using micro tasks:

function debouncedSubscribe(store, callback) {
    let key;
    return store.subscribe(value => {
        key = {};
        const currentKey = key;
        queueMicrotask(() => {
            if (key == currentKey)
                callback(value);
        });
    });
}

debouncedSubscribe(denormalizedContainers, value =>
    console.log(
        `Subscribe: There are ${value.length} denormalized containers`
    )
);

REPL example

There is no way to cancel micro tasks again, so the old ones are invalidated via a key. Other ways of ensuring that only the last task is executed might be more elegant.

With setTimeout this can be done as well, and with that the previous one can be cancelled, but the minimum delay will be larger:

function debouncedSubscribe(store, callback) {
    let timeout;
    return store.subscribe(value => {
        clearTimeout(timeout);
        timeout = setTimeout(() => callback(value));
    });
}
User contributions licensed under: CC BY-SA
9 People found this is helpful
Advertisement