Stores
Stores in the Migrating from Solid and SolidStart guide.
useStore is the one state hook. Given a plain object or array it returns [store, setStore] with fine-grained,
deep reactivity like createStore. Given anything else (a number, a string, a boolean) it returns [get, set] like
a signal. A value that can be null or change type is a single value too: it is replaced as a whole, and plain data
assigned to it is read fine-grained.
| Solid | ShadowJS | What differs |
|---|---|---|
createStore(init) |
useStore(init) |
store is a read-only view; assigning to it outside the setter throws. Only plain objects and arrays are wrapped; Date, Map, Set, class instances and functions are opaque values. |
setStore("a", 0, "b", v) |
setStore("a", 0, "b", v) |
Same path syntax. Typed up to four steps; deeper or dynamic paths go through a draft function. Steps: a key, an array of keys, an array filter (item, index) => boolean, or a range { from, to, by }. |
setStore(prev => ({ ...patch })), setStore({ ...patch }) |
same | Objects shallow-merge into the target. Arrays and primitives replace. A path updater receives a draft, not a plain value: see the append and remove forms. |
setStore(produce(d => { ... })) |
setStore(produce(d => { ... })) or setStore(d => { ... }) |
produce is exported for migration; the plain draft function does the same. A draft that escapes the call throws. |
setStore("list", reconcile(next, { key })) |
same | reconcile(next, { key, merge }). merge: true keeps keys absent from next (Solid's merge still deletes them). key: null (positional matching) is not accepted. |
unwrap(store) |
unwrap(store) |
Returns the raw object. snapshot(store) (ShadowJS only) returns a deep copy. |
store getters (get full() { return this.first }) |
same | Tracked: the getter's reads subscribe. |
setStore("k", undefined) deletes k |
delete draft.k |
Assigning undefined keeps the key. |
createMutable, modifyMutable |
none | Writes always go through the setter. |
Append and remove in a store#
Solid code often builds a new array inside the setter: setStore("todos", (todos) => [...todos, item]) or
(todos) => todos.filter(...). In ShadowJS the updater of a path receives a draft. An array built from the
draft's items is stored as plain data, so writing to one of those items afterward works (the table and the test
below use this form for filtering). A draft that escapes the call by another route, such as keeping draft in a
variable and writing through it later, still throws a draft was used after its setStore() call returned. These forms are
tested below.
| Intent | Form that works |
|---|---|
| Append | setStore("todos", store.todos.length, item) or setStore((d) => { d.todos.push(item); }) |
| Remove by index | setStore((d) => { d.todos.splice(index, 1); }) |
| Remove by predicate | setStore("todos", (todos) => snapshot(todos).filter((t) => t.id !== id)) |
| Reorder | setStore((d) => { d.todos.sort(compare); }) or d.todos.reverse() |
| Replace the list from the server | setStore("todos", reconcile(next, { key: "id" })) |
| Copy-and-spread | setStore("todos", (todos) => [...snapshot(todos), item]) |
Todo list: Solid and ShadowJS#
Solid (not executed):
import { createStore, produce } from "solid-js/store";
import { For } from "solid-js";
function Todos() {
const [state, setState] = createStore({ todos: [], text: "" });
const add = () =>
setState("todos", (todos) => [...todos, { id: Date.now(), title: state.text, done: false }]);
const toggle = (id) => setState("todos", (t) => t.id === id, "done", (d) => !d);
return (
<For each={state.todos}>{(todo) => <li onClick={() => toggle(todo.id)}>{todo.title}</li>}</For>
);
}
ShadowJS (the code is the same apart from the hook name; for rows are compiled and type-checked):
import { useMemo, useStore } from "@shadow-js/core";
import { For } from "@shadow-js/web";
interface Todo {
id: number;
title: string;
done: boolean;
}
export function Todos() {
const [state, setState] = useStore({
todos: [
{ id: 1, title: "Write docs", done: false },
{ id: 2, title: "Ship", done: false },
] as Todo[],
text: "",
});
let nextId = 3;
const remaining = useMemo(() => state.todos.filter((todo) => !todo.done).length);
const add = () => {
if (state.text === "") return;
setState("todos", state.todos.length, { id: nextId++, title: state.text, done: false });
setState("text", "");
};
const toggle = (id: number) => setState("todos", (todo) => todo.id === id, "done", (done) => !done);
return (
<section>
<p>Remaining: {remaining()}</p>
<input
aria-label="New todo"
value={state.text}
onInput={(event) => setState("text", event.currentTarget.value)}
/>
<button type="button" onClick={add}>
Add
</button>
<ul>
<For each={state.todos}>
{(todo) => (
<li>
<label>
<input type="checkbox" checked={todo.done} onChange={() => toggle(todo.id)} />
{todo.title}
</label>
</li>
)}
</For>
</ul>
</section>
);
}