How to use Svelte store with tree-like nested object?
Asked Answered
A

2

27

The Svelte official tutorial employs such complex object in its document for <svelte:self>

let root = [
    {
        type: 'folder',
        name: 'Important work stuff',
        files: [
            { type: 'file', name: 'quarterly-results.xlsx' }
        ]
    },
    {
        type: 'folder',
        name: 'Animal GIFs',
        files: [
            {
                type: 'folder',
                name: 'Dogs',
                files: [
                    { type: 'file', name: 'treadmill.gif' },
                    { type: 'file', name: 'rope-jumping.gif' }
                ]
            },
            {
                type: 'folder',
                name: 'Goats',
                files: [
                    { type: 'file', name: 'parkour.gif' },
                    { type: 'file', name: 'rampage.gif' }
                ]
            },
            { type: 'file', name: 'cat-roomba.gif' },
            { type: 'file', name: 'duck-shuffle.gif' },
            { type: 'file', name: 'monkey-on-a-pig.gif' }
        ]
    },
    { type: 'file', name: 'TODO.md' }
];

If this object needs to be reactive and placed inside a store, how should it be done? Should the tree be wrapped as a single store, or each file and folder is its own store and stores are nested accordingly?

In both cases, it seems whenever the top-level properties are changed (svelte store considers update from objects always fresh), the whole tree will be checked for change?

Aquarium answered 1/12, 2020 at 14:7 Comment(0)
S
49

A few things to know...

The $ prefix notations for stores also works to assign a new value to a writable store:

<script>
  import { writable } from 'svelte/store'

  const x = writable(0)

  const onClick = () => {
    $x = $x + 1
  }
</script>

<button on:click={onClick}>+</button>

<span>{$x}</span>

This also works to write to a single prop of an object, or individual items in an array:

<script>
  import { writable } from 'svelte/store'

  const x = writable({
    count: 0,
  })

  const onClick = () => {
    $x.count = $x.count + 1
  }
</script>

<button on:click={onClick}>+</button>

<span>{$x.count}</span>

From a parent component, you can bind a variable to a prop of a child component:

Child.svelte

<script>
  export let value
</script>

<input bind:value />

App.svelte

<script>
  import Child from './Child.svelte'

  let value = ''

  $: console.log(value)
</script>

<Child bind:value />

Note: bindings only works when it's the same variable. That is, you can't put the bound variable in an intermediate variable, and have Svelte keep tracking this binding. Svelte does keep tracking individual props of objects (as long as they're referenced from the originally bound variable -- with dot notation), and items of arrays though, notably in {#each} loops:

<script>
  import { writable } from 'svelte/store'

  const x = writable({
    count: 0,
  })
    
  const y = writable([
    { count: 0 },
    { count: 1 },
  ])

  const onClick = () => {
    $x.count = $x.count + 1
  }
</script>

<button on:click={onClick}>+</button>

<span>{$x.count}</span>

<hr />

{#each $y as item, i}
  <div>
    <button on:click={() => item.count++}>$y[{i}]: +</button>
  </div>
{/each}

<pre>{JSON.stringify($y)}</pre>

And so, knowing all this, if you put your source data in a writable store and you are precise with your 2-way bindings, you can end up with a pretty cheap solution to your question... (See in REPL)

stores.js

import { readable, writable, derived } from 'svelte/store'

// a big writable store
export const root = writable([
  {
    type: 'folder',
    name: 'Important work stuff',
    files: [{ type: 'file', name: 'quarterly-results.xlsx' }],
  },
  {
    type: 'folder',
    name: 'Animal GIFs',
    files: [
      {
        type: 'folder',
        name: 'Dogs',
        files: [
          { type: 'file', name: 'treadmill.gif' },
          { type: 'file', name: 'rope-jumping.gif' },
        ],
      },
      {
        type: 'folder',
        name: 'Goats',
        files: [
          { type: 'file', name: 'parkour.gif' },
          { type: 'file', name: 'rampage.gif' },
        ],
      },
      { type: 'file', name: 'cat-roomba.gif' },
      { type: 'file', name: 'duck-shuffle.gif' },
      { type: 'file', name: 'monkey-on-a-pig.gif' },
    ],
  },
  { type: 'file', name: 'TODO.md' },
])

App.svelte

<script>
  import { root } from './stores.js'
  import Folder from './Folder.svelte'

  $: console.log($root)
</script>

<div class="hbox">
  <div>
    <!-- NOTE binding to the store itself: bind=files={root} -->
    <Folder readonly expanded bind:files={$root} file={{ name: 'Home' }} />
  </div>
  <pre>{JSON.stringify($root, null, 2)}</pre>
</div>

<style>
  .hbox {
    display: flex;
    justify-content: space-around;
  }
</style>

Folder.svelte

<script>
  import File from './File.svelte'

  export let readonly = false
  export let expanded = false

  export let file
  export let files

  function toggle() {
    expanded = !expanded
  }
</script>

{#if readonly}
  <!-- NOTE bindings must keep referencing the "entry" variable 
       (here: `file.`) to be tracked -->
  <span class:expanded on:click={toggle}>{file.name}</span>
{:else}
  <label>
    <span class:expanded on:click={toggle} />
    <input bind:value={file.name} />
  </label>
{/if}

{#if expanded}
  <ul>
    {#each files as file}
      <li>
        {#if file.type === 'folder'}
          <!-- NOTE the intermediate variable created by the #each loop 
               (here: local `file` variable) preserves tracking, though -->
          <svelte:self bind:file bind:files={file.files} />
        {:else}
          <File bind:file />
        {/if}
      </li>
    {/each}
  </ul>
{/if}

<style>
  span {
    padding: 0 0 0 1.5em;
    background: url(tutorial/icons/folder.svg) 0 0.1em no-repeat;
    background-size: 1em 1em;
    font-weight: bold;
    cursor: pointer;
        min-height: 1em;
        display: inline-block;
  }

  .expanded {
    background-image: url(tutorial/icons/folder-open.svg);
  }

  ul {
    padding: 0.2em 0 0 0.5em;
    margin: 0 0 0 0.5em;
    list-style: none;
    border-left: 1px solid #eee;
  }

  li {
    padding: 0.2em 0;
  }
</style>

File.svelte

<script>
  export let file

  $: type = file.name.slice(file.name.lastIndexOf('.') + 1)
</script>

<label>
  <span style="background-image: url(tutorial/icons/{type}.svg)" />
  <input bind:value={file.name} />
</label>

<style>
  span {
    padding: 0 0 0 1.5em;
    background: 0 0.1em no-repeat;
    background-size: 1em 1em;
  }
</style>

Note, however, that this might not be the most efficient solution.

The reason is that any change to any part of the store will be detected as a change to the whole store, and so Svelte will have to propagate and re-validate the change to every consumers (components) or this data. We're not necessarily talking about some heavy processing, because Svelte still knows the data graph and will short-circuit most of the propagation very early with very cheap and chirurgically targeted if tests. But still, the complexity of the processing will grow linearly (albeit slowly) with the size of the object in the store.

In some cases where the data can be very big or something (maybe allow for lazy fetching of the nested nodes?), you may want to elaborate around the techniques demonstrated in the above examples. For example, you could cap the algorithmic complexity (cost) of processing a change by wrapping the recursive nodes in your data (i.e. the files prop in the above example) each in a writable store. Yes, that would be stores in stores (high order stores?). This would surely be a little delicate to wire together, but that would theoretically give you near infinite scalability, because every change will only propagate to the siblings of the impacted node, instead of the whole tree.

Severus answered 2/12, 2020 at 10:25 Comment(7)
Thanks for the crash course, however the example really demonstrates how to use complex object directly in svelte, not through a store, since you unwrap the store in the root and then the child components only deals with plain objects.Aquarium
Also, when a file name is changed, the store really doesn't know that, since itself is never notified the change. In other words, the file name change is unobservable from outside world, unless the component emits a custom even or something, but that means stores don't play any part in this.Aquarium
Really? What makes you think that? I thought the JSON dump on the right demonstrated that the store was indeed modified. Have you tried subscribing to the store manually to confirm it didn't know about the change?Severus
Sorry, I should have tried it out before jumping into conclusions. You are right, svelte store is smart enough to detect changes deep within a store data. And when the data is tree-like, I guess you're bound to pass branches down recursively. Thanks for the detailed explanation.Aquarium
I'm surprised! Where is that deep observation of a store documented? Can you provide a link? Really useful indeed.Spinet
@Spinet That's not really a special case of "deep observation of the store", it's the general behaviors of, on one hand two way binding and, on the other hand writable stores and store magic notation. "Deep observation" emerges from the combination of those behaviors that are mostly documented and illustrated separately in docs and examples. Take away: Svelte gives you powerful simple atoms and behaves predictably when you compose them. Leverage the power, the glue is your thinking, go creative! (Albeit beware of not going too far, simpler is better -- as just illustrated.)Severus
chirurgically > surgicallyEdlun
P
5

Note to myself:

https://github.com/sveltejs/svelte/issues/1435#issuecomment-735233175

Start with a single store with all your global state and then split off views from that main store. As a proof of concept I have written a tool called subStore. Examples and links to repl can be found here https://github.com/bradphelan/immer.loves.svelte

And https://github.com/PixievoltNo1/svelte-writable-derived#making-an-object-store-from-several-single-value-stores

Primacy answered 20/1, 2022 at 8:5 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.