DEV Community

YAMAMOTO Yuji
YAMAMOTO Yuji

Posted on

Why I failed to create the "Solid.js's store" for Svelte, and announcing svelte-store-tree v0.3.1

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;
};
Enter fullscreen mode Exit fullscreen mode

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" },
]);
Enter fullscreen mode Exit fullscreen mode

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/ReadableTrees and set WritableTrees. 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}
Enter fullscreen mode Exit fullscreen mode

See in GitHub

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 an Accessor object.
    • An Accessor object implements a method to get the child C from the parent P, and a method to replace the child C of the parent P.
  • zoomNoSet<C>(readChild: (parent: P) => C | Refuse): ReadableTree<C>
    • Takes a function to get the child C from the parent P and then returns a ReadableTree object.
    • ReadableTree is a store tree that can't set. But child store trees zoomed from a ReadableTree can set. So it is NOT completely read-only.

In the example app, I created WritableTrees for writing the key and value field of the KeyValue object, using the into function for building Accessors:

const key = tree.zoom(into("key"));
const value = tree.zoom(into("value"));
Enter fullscreen mode Exit fullscreen mode

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)
...
Enter fullscreen mode Exit fullscreen mode

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 subscriber 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));
...
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

See in GitHub

A WritableTree object returned by choose calls the subscriber 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"));
Enter fullscreen mode Exit fullscreen mode

See in GitHub

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>
Enter fullscreen mode Exit fullscreen mode

See in GitHub

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>
Enter fullscreen mode Exit fullscreen mode

Then, the value set to key is conveyed to the functions subscribeing the key itself, or key's direct ancestors including keyValue. Functions subscribeing sibling stores such as value or the parents' siblings, don't see the update.

To illustrate, imagine the tree were shaped as follows:

Tree whose root is List 1, and the children are List 2, string and KeyValue

When updating the Key, the update is propagated to the three stores: List 1, KeyValue, and Key.

Update the tree whose root is List 1, and the children are List 2, string and KeyValue

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>
...
Enter fullscreen mode Exit fullscreen mode

See in GitHub

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 },
  ]
});
Enter fullscreen mode Exit fullscreen mode

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 + '!');
Enter fullscreen mode Exit fullscreen mode

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 subscriber functions if its foo property is not undefined:

store.zoom(into('foo').and(isPresent()));
Enter fullscreen mode Exit fullscreen mode

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")));
Enter fullscreen mode Exit fullscreen mode

Why don't I define the zoom method to receive multiple Accessors 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 Accessors 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 Accessors. Composing Accessors 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 Accessors 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}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 numbers 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.


  1. 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. 

  2. "zoom", "choose", and "refuse" rhyme. 

  3. 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 as choose does. 

  4. The top-level here seems to mean the top in the <script> block of the component. 

  5. 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. 

Top comments (0)