Why I failed to create the "Solid.js's store" for Svelte, and announcing svelte-store-tree v0.3.1
YAMAMOTO Yuji
Posted on November 8, 2022
Recently I released a new version of svelte-store-tree. It's a state management library for Svelte that I started to develop two months ago, and redesigned its API in the latest version. Today, let me introduce the detailed usage of svelte-store-tree, and compare with a similar library: Solid.js's store feature. Then, I'll share you with a problem of Svelte's store. I'm glad if this article helps you review the design of Svelte in the future.
Summary
- svelte-store-tree is a state management library for managing tree-like nested structures in Svelte.
- I referred a similar library, Solid.js's store, before creating it. But by contrast with Solid.js's store, svelte-store-tree requires selecting the value inside the store with its
choose
function to handle union type values. It's due to the design of Svelte's store that confines "the API to read the current value of the store" and "the API to update the value of the store" into a single store object.
Introduction to svelte-store-tree
As the name shows, svelte-store-tree adds1 methods to the store objects of Svelte, to easily work with trees (nested structures). I developed it for building a complex tree structure intuitively in my current job with Svelte.
Example Usage
From this section, I'll use the type below in the example app you can run here.
export type Tree = string | undefined | KeyValue | Tree[];
export type KeyValue = {
key: string;
value: string;
};
It's really a recursive structure with various possible values.
NOTE: I uploaded the code of the example app onto GitHub, so I'll put the link to the corresponding lines in GitHub after quoting the part of the code.
Creating a WritableTree
Object
To create a WritableTree
object provided by svelte-store-tree, use the writableTree
function literally:
export const tree = writableTree<Tree>([
"foo",
"bar",
["baz1", "baz2", "baz3"],
undefined,
{ key: "some key", value: "some value" },
]);
In the same way with the original writable
function, writableTree
receives the initial value of the store as its argument. Because Tree
is a union type, we should specify the type parameter to help type inference in the example above.
subscribe
and set
like the original store of Svelte
svelte-store-tree complies with the store contract of Svelte. Accordingly prefixing the variable name with a dollar sign $
, we can subscribe
WritableTree
/ReadableTree
s and set
WritableTree
s. Of course two-way binding to the part of the store tree is also available!
<script lang="ts">
export let tree: WritableTree<Tree>;
...
</script>
...
{#if typeof $tree === "string"}
<li>
<NodeTypeSelector label="Switch" bind:selected {onSelected} /><input
type="text"
bind:value={$tree}
/>
</li>
...
{/if}
Create a WritableTree
for a part of the tree by zoom
svelte-store-tree provides a way to make a store for some part of the tree as these methods whose name begins with zoom
:
-
zoom<C>(accessor: Accessor<P, C>): WritableTree<C>
- Returns a new
WritableTree
object with anAccessor
object. - An
Accessor
object implements a method to get the childC
from the parentP
, and a method to replace the childC
of the parentP
.
- Returns a new
-
zoomNoSet<C>(readChild: (parent: P) => C | Refuse): ReadableTree<C>
- Takes a function to get the child
C
from the parentP
and then returns aReadableTree
object. -
ReadableTree
is a store tree that can'tset
. But child store treeszoom
ed from aReadableTree
canset
. So it is NOT completely read-only.
- Takes a function to get the child
In the example app, I created WritableTree
s for writing the key
and value
field of the KeyValue
object, using the into
function for building Accessor
s:
const key = tree.zoom(into("key"));
const value = tree.zoom(into("value"));
However, this isn't correct. svelte-check warns me of type errors detected by TypeScript:
/svelte-store-tree/example/Tree.svelte:14:35
Error: Argument of type 'string' is not assignable to parameter of type 'never'. (ts)
...
/svelte-store-tree/example/Tree.svelte:15:37
Error: Argument of type 'string' is not assignable to parameter of type 'never'. (ts)
...
The messages are somewhat confusing as it says parameter of type 'never'
. The type checker means the result of keyof Tree
is never
because the type of tree
is WritableTree<Tree>
, whose content Tree
is by definition a union type that can be undefined
.
To fix the problem, we have to convert the WritableTree<Tree>
into WritableTree<KeyValue>
by some means. The choose
function helps us in such a case.
Subscribe the value only fulfilling the condition using choose
We can create a store tree that calls the subscribe
r functions only if the store value matches with the specific condition, by calling zoom
with an Accessor
from the choose
function.
Here I used the choose
function in the example app:
...
const keyValue = tree.zoom(choose(chooseKeyValue));
...
choose
takes a function receiving a store value to return some other value, or Refuse
. The chooseKeyValue
function is the argument of choose
in the case above. It's defined as following:
export function chooseKeyValue(tree: Tree): KeyValue | Refuse {
if (tree === undefined || typeof tree === "string" || tree instanceof Array) {
return Refuse;
}
return tree;
}
A WritableTree
object returned by choose
calls the subscribe
r functions when the result of the function (passed to choose
) is not Refuse
, a unique symbol dedicated for this purpose2.
You might wonder why choose
doesn't judge by a boolean value or refuse undefined
instead of the unique symbol. First, the function given to choose
must specify the return type to make use of the narrowing feature of TypeScript. The user-defined type guard doesn't help us with a higher-order function like choose
. Second, I made the unique symbol Refuse
to handle objects with nullable properties such as { property: T | undefined }
properly.
Let's get back to the problem of converting the WritableTree<Tree>
into WritableTree<KeyValue>
. The error by tree.zoom(into("key"))
is corrected using choose
:
const keyValue = tree.zoom(choose(chooseKeyValue));
const key = keyValue.zoom(into("key"));
const value = keyValue.zoom(into("value"));
Finally, we can two-way bind the key
and value
returned by zoom
:
<dl>
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
</dl>
Parents can react to updates by their children
All examples so far doesn't actually need svelte-store-tree. Just managing the state in each component would suffice instead of managing the state as a single large structure. Needless to say, svelte-store-tree does more: it can propagate updates in the children only to their direct parents.
For example, assume that the <input>
bound with the key
from the last example got new input.
<dl>
<!--------- THIS <input> ------------->
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
</dl>
Then, the value set
to key
is conveyed to the functions subscribe
ing the key
itself, or key
's direct ancestors including keyValue
. Functions subscribe
ing sibling stores such as value
or the parents' siblings, don't see the update.
To illustrate, imagine the tree were shaped as follows:
When updating the Key, the update is propagated to the three stores: List 1, KeyValue, and Key.
The Value, string, List 2, and List 2's children don't know the update. Thus, Svelte can avoid extra rerenderings.
To demonstrate that feature, the example app sums up the number of the nodes in the tree by their type:
<script lang="ts">
import TreeComponent from "./Tree.svelte";
import { tree, type NodeType, type Tree } from "./tree";
$: stats = countByNode($tree);
function countByNode(node: Tree): Map<NodeType, number> {
...
}
</script>
<table>
<tr>
<th>Node Type</th>
<th>Count</th>
</tr>
{#each Array.from(stats) as [nodeType, count]}
<tr>
<td>{nodeType}</td>
<td>{count}</td>
</tr>
{/each}
</table>
...
V.S. Solid.js's Store
As I wrote in the beginning of this article, I referred Solid.js's store feature in developing svelte-store-tree v0.3.1 because it's made for the similar goal with svelte-store-tree. From now on, let me explain in what way svelte-store-tree is similar to Solid.js's store, and what feature of Solid.js's store it failed to support because of the limitation of Svelte's store, respectively. I hope this would be a hint for discussion on the future design of Svelte.
Quick introduction to Solid.js's Store
Solid.js's store, as the page I referred in the last section described as "Solid's answer to nested reactivity", enables us to update a part of nested structures and track the part of the state. The createStore
function returns a pair of the current store value, and the function to update the store value (slightly similar to React's useState
):
ℹ️ All examples in this section are quoted from the official web site.
const [state, setState] = createStore({
todos: [
{ task: 'Finish work', completed: false },
{ task: 'Go grocery shopping', completed: false },
{ task: 'Make dinner', completed: false },
]
});
The first thing of the pair, state
is wrapped by a Proxy so that the runtime can track access to the value of the store including its properties.
Using the second value of the pair setState
, we can specify "path" objects, such as names of the properties and predicate functions to decide which elements to update (if the value is an array), to tell which part of the store value should be updated. For example:
// Set true to the `completed` property of the 0th and 2nd elements in the `todos` property.
setState('todos', [0, 2], 'completed', true);
// Append `'!'` to the `task` property of `todos` elements whose `completed` property is `true`.
setState('todos', todo => todo.completed, 'task', t => t + '!');
The setState
is so powerful that it can update arbitrary depth of the nested structure without any other library functions.
In addition, as introduced before, Solid.js's store is designed for separately using the Proxy
-wrapped value and the function to update, while Svelte's store is designed for using as a single object with set
and subscribe
to make it available for two-way binding.
Feature that affected svelte-store-tree
Somewhat affected by Solid.js's store, I improved the Accessor
API of svelte-store-tree, used to show how to get deeper into the nested structure. Specifically, svelte-store-tree
can now compose Accessor
objects by their and
method as the setState
function of Solid.js (the second value of the createStore
's result) can compose the "path" objects given as its arguments.
For example, by combining the into
and isPresent
Accessor
, we can make a store that calls subscribe
r functions if its foo
property is not undefined
:
store.zoom(into('foo').and(isPresent()));
Thanks to them, the code to obtain the key
property from the tree
in the example app can be rewritten like below:
const key = tree.zoom(choose(chooseKeyValue).and(into("key")));
Why don't I define the zoom
method to receive multiple Accessor
s to compose? One of the reasons is to simplify the implementation of zoom
, and another is that it's not a good idea to pile up many Accessor
s at once to access to too deep part of the nested structure, in my opinion.
In detail, the former means that the code of zoom
is more concise because it doesn't have to take care of multiple Accessor
s. Composing Accessor
s is just the business of the and
method.
And the latter is a design issue. Diving several levels down into a nested structure at a time is like surgery for the internal: it incurs a risk that the code gets vulnerable to change. Besides, composing Accessor
s by listing them in the arguments can't work well for recursively nested structures, which I suppose to be a primary use case of svelte-store-tree. Because their depth varies dynamically.
I determined this design based on the usage I assumed, at the sacrifice of verbosity of composing the and
methods.
Feature that svelte-store-tree failed to support
I made svelte-store-tree aim for "the Solid.js's store for Svelte". But I couldn't reproduce one of its features anyway: Solid.js does NOT need a feature equivalent to svelte-store-tree's choose
function3. I wasted a section for describing choose
in a way!
Why doesn't Solid.js's store require a feature like choose
? That is related to its design that the object to read the store value and the function to update the store value are separated.
Solid.js's store can select the value only by branching in the component on its value using Show
or Switch
/ Match
(if
statements in Solid.js).
The same seems appied to svelte-store-tree, but that isn't true. Recall the first example of choose
:
<script lang="ts">
// ...
const keyValue = tree.zoom(choose(chooseKeyValue));
const key = keyValue.zoom(into("key"));
const value = keyValue.zoom(into("value"));
// ...
</script>
{#if typeof $tree === "string"}
...
{:else if $tree === undefined}
...
{:else if $tree instanceof Array}
...
{:else}
...
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
...
{/if}
The two stores key
and value
are used only when the value of tree
is neither string
, undefined
, nor Array
, but KeyValue
, as it's chosen by the {#if} ... {/if}
. Getting key
and value
via choose(chooseKeyValue)
is actually repeating the narrowing-down by the {#if} ... {/if}
.
So you might try modify the component like following with {@const ...}
after key
and value
are used only in the {:else}
:
{:else}
...
{@const key = tree.zoom(into("key"))}
{@const value = tree.zoom(into("value"))}
<dt><input type="text" bind:value={$key} /></dt>
<dd><input type="text" bind:value={$value} /></dd>
...
{/if}
Though svelte-check raises an error about the {@const ...}
below:
/svelte-store-tree/example/Tree.svelte:69:42
Error: Stores must be declared at the top level of the component (this may change in a future version of Svelte) (svelte)
It says that stores must be declared at the top-level4, thus you can't define a new store in a {@const ...}
to subscribe
. It's impossible to make a store only on a specific condition so far.
To avoid that error forcibly, you have the option to split out a component containing only <input type="text" bind:value={$key} />
and <input type="text" bind:value={$value} />
then make the component take a WritableTree<KeyValue>
. However, there is still a type error: the branching beginning with {#if typeof $tree === "string"}
narrows only $tree
, that is the current value of tree
, so doesn't narrow the tree
to WritableTree<KeyValue>
from WritableTree<Tree>
. TypeScript doesn't take that into consideration.
Due to the problem I showed here, I gave up adding the feature following the exisiting convention of the store of Svelte.
Why do we have to narrow the store itself as well as its value in Svelte? Because Svelte assigns both the API to read the store value (subscribe
) and the API to update (set
etc.) to the same single object. I'll simplify WritableTree
to explain the detail:
WritableTree<T> = {
// Get the current value of the store.
// I replaced the `subscribe` method with this for simplicity.
get: () => T;
// Set a new value of the store. This is same as the original `set`.
set: (newValue: T) => void;
};
Let's give a concrete type such as number | undefined
to WritableTree
to instantiate a store whose value can contain undefined
:
WritableTree<number | undefined> = {
get: () => number | undefined;
set: (newValue: number | undefined) => void;
};
Now, let's say we want to remove the undefined
to convert it to WritableTree<number>
. Code to use the get
method of WritableTree<number>
doesn't expect get
to return undefined
, consequently we have to make it return only number
. Meanwhile code to write a number
on WritableTree<number>
just passes number
s to the set
method, then we can reuse the function (newValue: number | undefined) => void
as is5.
Hence, what we have to narrow is only the API to read the store value in fact. While Solid.js's store just has to narrow the current store value by branching, svelte-store-tree has to narrow down both the functions because it forces a single object to contain both the method to read and the method to set.
-
Technically speaking, I implemented svelte-store-tree by rewriting the code of the store after copy-and-pasting it. So it doesn't add the methods. ↩
-
"zoom", "choose", and "refuse" rhyme. ↩
-
The feature to filter the store value with predicate functions in Solid.js, sounds similar to
choose
, but it's available only when the store value is an array. Note that it's not for narrowing types aschoose
does. ↩ -
The top-level here seems to mean the top in the
<script>
block of the component. ↩ -
This difference is widely known as "covariant and contravariant". The Japanese book "Introduction to TypeScript for Those Who Wants to Be a Pro" (プロを目指す人のためのTypeScript入門) also explains. ↩
Posted on November 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 8, 2022