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.
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` ) );
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)); }); }