How to use Svelte store with tree-like nested object?
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 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 revalidate 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.