Svelte: Automatic subscriptions to nested stores

Created on 9 Dec 2019  路  9Comments  路  Source: sveltejs/svelte

(Note sure if this is a feature request or a bug...)

Is your feature request related to a problem? Please describe.
I've implemented a custom store that's essentially:

function createMapStore(initial) {
  const backingStore = writable(initial);
  const { subscribe, update } = backingStore;
  const set = (key, value) => update(m => Object.assign({}, m, {[key]: value}));
  return {
    subscribe,
    set,
    remove: (key) => set(key, undefined),
    keys: derived(backingStore, bs => Object.keys(bs)),
    values: derived(backingStore, bs => Object.values(bs)),
    entries: derived(backingStore, bs => Object.entries(bs)),
  }
}

In theory this would allow me to do things like

{#each $store.values as value}

However, this doesn't appear to work. I get the following error:

bundle.js:1505 Uncaught TypeError: Cannot read property 'length' of undefined
    at create_fragment$4 (bundle.js:1505)
    at init (bundle.js:390)
    at new Home (bundle.js:1577)
    at Array.create_default_slot_3 (bundle.js:2548)
    at create_slot (bundle.js:48)
    at create_if_block (bundle.js:1080)
    at create_fragment$3 (bundle.js:1128)
    at init (bundle.js:390)
    at new TabPanel (bundle.js:1239)
    at Array.create_default_slot$2 (bundle.js:2674)

bundle.js:89 Uncaught (in promise) TypeError: Cannot read property 'removeAttribute' of undefined
    at attr (bundle.js:89)
    at attr_dev (bundle.js:455)
    at update (bundle.js:856)
    at updateProfile (<anonymous>:49:7)
    at Object.block.p (<anonymous>:261:9)
    at update (bundle.js:188)
    at flush (bundle.js:162)

I can work around this by doing:

<script>
import { store } from './stores.js';
$: values = store.values;
</script>

{#each $values as item}

Describe the solution you'd like
Ideally, I'd simply be able to do:

<script>
import { store } from './stores.js';
</script>

{#each store.values as item}

Describe alternatives you've considered
See the $: values = store.values; approach above.

How important is this feature to you?
It's not super important, but so far Svelte has had excellent emphasis on ergonomics, so it's a bit of a shame that this doesn't work.

reactivity proposal

Most helpful comment

@Conduitry instead of closing immediately, could we discuss some syntaxes that might work? I'm hardly an expert, so I'm not sure I can propose a syntax, but I can try to get the ball rolling:

{#each $(stores.values) as item} <!-- might not play well with jQuery / $ selectors -->
// or
{#each $stores.$values as item}
// or
{#each stores.$values as item}

would all be in the realm of possibility for me. Certainly nicer than requiring a const destructuring each time. Proposed workarounds get particularly awkward for multiple stores

<script>
import { storeA, storeB, storeC } from './stores';
const { aKeys } = storeA;
const { bKeys } = storeB;
const { cKeys } = storeC;
$: aCount = $aKeys.length;
$: bCount = $bKeys.length;
$: cCount = $cKeys.length;
</script>
<div># of As: {aCount}</div>
<div># of Bs: {bCount}</div>
<div># of Cs: {cCount}</div>

as opposed to

<script>
import { aStore, bStore, cStore } from './stores.js';
</script>
<!-- Each syntax shown -->
<div># of As: {$(aStore.keys).length}</div>
<div># of Bs: {$bStore.$keys.length}</div>
<div># of Cs: {cStore.$keys.length}</div>

All 9 comments

#each store.values as item} isn't really an option, because store.values isn't an array - it's a store containing an array.

$store.values means the .values key in the object contained in the store store, which is not the situation you have.

If you expect store.values itself to change (and not just the value in it), then something like the reactive declaration $: values = store.values; is what is recommended. If it's not going to change, you can just do const { values } = store; and then use $values.

As the docs indicate, autosubscription to stores only works with top-level variables. There are some situations where it would be nice to be able to do more than this - but one of the things in the way of that is there not being a nice syntax for it, and I don't think this issue suggests one. Closing,

@Conduitry instead of closing immediately, could we discuss some syntaxes that might work? I'm hardly an expert, so I'm not sure I can propose a syntax, but I can try to get the ball rolling:

{#each $(stores.values) as item} <!-- might not play well with jQuery / $ selectors -->
// or
{#each $stores.$values as item}
// or
{#each stores.$values as item}

would all be in the realm of possibility for me. Certainly nicer than requiring a const destructuring each time. Proposed workarounds get particularly awkward for multiple stores

<script>
import { storeA, storeB, storeC } from './stores';
const { aKeys } = storeA;
const { bKeys } = storeB;
const { cKeys } = storeC;
$: aCount = $aKeys.length;
$: bCount = $bKeys.length;
$: cCount = $cKeys.length;
</script>
<div># of As: {aCount}</div>
<div># of Bs: {bCount}</div>
<div># of Cs: {cCount}</div>

as opposed to

<script>
import { aStore, bStore, cStore } from './stores.js';
</script>
<!-- Each syntax shown -->
<div># of As: {$(aStore.keys).length}</div>
<div># of Bs: {$bStore.$keys.length}</div>
<div># of Cs: {cStore.$keys.length}</div>

I had this problem and figured out how to make something like this work with the compiler quirks. In essence, the compiler will only make whatever is immediately attached to the $ reactive. This means in {#each $store.values as value} only $store is reactive, and $store returns your JavaScript object (initial) which doesn't have a values property.

You can fix this by having a derived store off your backing store that returns an object with keys, values, and entries properties. I've quickly rigged an example of this working here: https://svelte.dev/repl/ccbc94cb1b4c493a9cf8f117badaeb31?version=3.16.7

Shameless plug: I've created a package called Svue to make complex store patterns more tractable with Svelte and play nicely with the $ reactive syntax. It's admittedly early stages and could be cleaned up a bit with respect to nested properties, but here's an example of a structure like what you're doing above using Svue: https://svelte.dev/repl/2dd2ccc8ebd74e97a475db0b0da244d9?version=3

I've worked around this by creating a derived store that's basically just creating a new object with the values of the other stores.

I created a class to communicate with a Firestore collection that looks like this

import firebase from "../firebase"
import { writable, readable, derived } from "svelte/store"

export default class firestoreCollection {
    constructor(name) {
        this.name = name
        this.ref = firebase.firestore().collection(name)
        this.loading = writable(false)
        this.loadingError = writable(null)
        this.dict = readable([], (set) => {
            console.log("subscribing to", this.name)
            this.loading.update((p) => true)
            this.ref.onSnapshot(
                (s) => {
                    this.loading.update((p) => false)
                    const entities = {}
                    s.forEach((doc) => {
                        entities[doc.id] = { id: doc.id, ...doc.data() }
                    })
                    this.loadingError.update((p) => null)
                    console.log("onSnapshot", this.name, "entities:", entities)
                    set(entities)
                },
                (e) => {
                    console.error("failed to load entities", this.name, e)
                    this.loading.update((p) => false)
                    this.loadingError.update((p) => e)
                }
            )
        })
        this.entities = derived(this.dict, ($dict) => {
            return $dict ? Object.values($dict) : []
        })

        this.adding = writable(false)
        this.addError = writable(null)

        this.updating = writable(false)
        this.updateError = writable(null)

        this.store = derived(
            [
                this.loading,
                this.loadingError,
                this.adding,
                this.addError,
                this.updating,
                this.updateError,
                this.entities,
            ],
            ([$loading, $loadingError, $adding, $addError, $updating, $updateError, $entities]) => {
                return {
                    loading: $loading,
                    loadingError: $loadingError,
                    adding: $adding,
                    addError: $addError,
                    updating: $updating,
                    updateError: $updateError,
                    entities: $entities,
                }
            }
        )
    }

    async add(newEntity) {
        try {
            this.adding.update((p) => true)
            await this.ref.add(newEntity)
            this.adding.update((p) => false)
            this.addError.update((p) => null)
        } catch (e) {
            console.error("add failed", this.name, newEntity, e)
            this.addError.update((p) => e)
        }
    }

    async update({ id, ...updatedEntity }) {
        try {
            this.updating.update((p) => id)
            await this.ref.doc(id).set(updatedEntity)
            this.updating.update((p) => false)
            this.updateError.update((p) => null)
        } catch (e) {
            console.error("failed to update", this.name, id, e)
            this.updating.update((p) => false)
            this.updateError.update((p) => ({ id, error: e }))
        }
    }
}

Then I'd do

import firestoreCollection from "../firebase/firestoreCollection"

const principleCollection = new firestoreCollection("principles")
export default principleCollection

And import this into my component

import principleCollection from "./store/principles";
$: principles = principleCollection.store;
  {#if $principles.loading}
    <p>Loading principles...</p>
  {:else}
    {#if $principles.loadingError}
      <p class="text-red-500">{$principles.loadingError.message}</p>
    {:else if $principles.entities && $principles.entities.length}
      <div class="flex flex-row flex-wrap">
        {#each $principles.entities as principle (principle.id)}
          <Principle {principle} on:save={savePrinciple(principle.id)} />
        {/each}
      </div>
    {:else}
      <p>No principles yet</p>
    {/if}
    <button
      on:click={e => principleCollection.add({ content: 'My new principle' })}>
      Add new
    </button>
  {/if}

While this works fine, I would have preferred to be able to directly access the instance stores like so

{#if $(principleCollection.loading)}

This would avoid having to create a whole derived store that's basically just repeating three times every variable name.
Not sure if there's a better way that allows not to have to use destructuring because, as pointed out previously, if I add a tagCollection then I can't just do const { loading } = principleCollection anymore or I have to repeat and rename everything by doing $: principlesLoading = principleCollection.loading and $: tagsLoading = tagCollection.loading which is definitely not what I want.

I'd like to see if can implement this, I've started to look at the code for Svelte. I've noticed areas of interest seem to be in the Component.ts file of the compiler.
Any pointers on what needs to be changed to accomplish this ?

@skflowne yea, what you're describing is essentially the workaround I mentioned in my initial comment, I could never find a cleaner way to do it either.

Can someone explain why it's not working this way right now ?
Is there some major technical issue related to extracting nested variables in template expressions ?
Or is it just about agreeing on syntax ?

It would be great if there were some syntax for directly subscribing to stores in properties of objects.
So ideally just regularObject.$childStore and $store.$childStore or if that somehow is not an option maybe the $ can be nested via parentheses like $(regularObject.childStore) and $($store.childStore).

Currently the issue often comes up with for-each blocks because for singular instances one can just pull the property to the top level and then use that (it is still not intuitive). So for example:

<script>
     export let model;
     $: isEnabled = model.isEnabled;
</script>
<button disabled={$isEnabled == false}>{model.label}</button>

Thus, another workaround is to create a new top level scope for each item by wrapping the content of a for-each block in a new component. That is hardly ideal and i have been thinking that being able to add code to the for-each scope would be a useful capability in itself. (One can already destructure the loop variable but using a store obtained that way currently throws an error 馃檨 - Stores must be declared at the top level of the component (this may change in a future version of Svelte))

Example with fantasy syntax:

{#each buttonModels as buttonModel {
    // Code block with access to for-each item scope.
    const isEnabled = buttonModel.isEnabled;
}}
    <button disabled={$isEnabled == false}>{model.label}</button>
{/each}

This could also be used for getting some item-level data on the fly without the need to map over the source array or having overly long expressions in attribute bindings and slots.

Here's a proxy store I wrote to derive the value of a store nested within other stores, it plays nice with typescript and can go infinitely deep

type Cleanup = () => void;
type Unsubscriber = () => void;
type CleanupSubscriber<T> = (value: T) => Cleanup | void;

type p<l, r> = (v: l) => Readable<r>;

export function proxy<A, B>(store: Readable<A>, ...arr: [p<A, B>]): Readable<B>;
export function proxy<A, B, C>(store: Readable<A>, ...arr: [p<A, B>, p<B, C>]): Readable<C>;
export function proxy<A, B, C, D>(store: Readable<A>, ...arr: [p<A, B>, p<B, C>, p<C, D>]): Readable<D>;
export function proxy<A, B, C, D, E>(store: Readable<A>, ...arr: [p<A, B>, p<B, C>, p<C, D>, p<D, E>]): Readable<E>;
export function proxy(store: Readable<any>, ...arr: p<any, any>[]) {
    const max = arr.length - 1;
    return readable(null, (set) => {
        const l = (i: number) => (p) => {
            const q = arr[i](p);
            if (!q) set(null);
            else return i === max ? q.subscribe(set) : subscribe_cleanup(q, l(i + 1));
        };
        return subscribe_cleanup(store, l(0));
    });
}
function subscribe_cleanup<T>(store: Readable<T>, run: CleanupSubscriber<T>): Unsubscriber {
    let cleanup = noop;
    const unsub = store.subscribe((v) => {
        cleanup();
        cleanup = run(v) || noop;
    });
    return () => {
        cleanup();
        unsub();
    };
}

Simply supply your store followed by however many functions are needed to derive from the value of each nested store
https://svelte.dev/repl/d2c8c1697c0f4ac3b248889ec329f512?version=3.24.1

const deepest = readable("success!");
const deeper = readable({ deepest });
const deep = readable({ deeper });
const store = readable({ deep });
const res = proxy(
    store,
    ($store) => $store.deep,
    ($deep) => $deep.deeper,
    ($deeper) => $deeper.deepest
);
console.log($res); // "success!"

A slightly different use case. I often use functions that return stores. Now I do something like this

$: PRODUCT = watchProduct(product_id)
$: product = $PRODUCT
<h1>{product.title}</h1>
{product.description}

or

$: product$ = watchProduct(product_id)
<h1>{$product$.title}</h1>
{$product$.description}

but I'd prefer instead a less noisy

$: product = $(watchProduct(product_id))
<h1>{product.title}</h1>
{product.description}

(Actually maybe this could be done with a Babel Macro.)

Was this page helpful?
0 / 5 - 0 ratings