Skip to content

Instantly share code, notes, and snippets.

@intrnl
Last active January 29, 2024 21:59
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save intrnl/9b9313184df1763c5a12cbff3d975c4a to your computer and use it in GitHub Desktop.
Save intrnl/9b9313184df1763c5a12cbff3d975c4a to your computer and use it in GitHub Desktop.
Svelte 5 deep reactivity
<svelte:options runes />
<script>
import { store, increment } from './reactive.js';
const deep = store.deep;
const remaining = $derived(store.items.filter((item) => !item.done).length);
function push() {
store.items.push({ text: 'bar', done: false });
}
function replace() {
store.items[0] = { text: 'baz', done: false };
}
</script>
<button on:click={increment}>
counter: {deep.count}
</button>
<button on:click={push}>push</button>
<button on:click={replace}>replace</button>
<table>
<tbody>
<!-- see comments below as to why (item) is necessary -->
{#each store.items as item (item)}
<tr>
<td><input type="checkbox" bind:checked={item.done} /></td>
<td><input type="text" bind:value={item.text} /></td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td colspan={2}>
{remaining} items remaining
</td>
</tr>
</tfoot>
</table>
<pre>{JSON.stringify(store, null, 2)}</pre>
// props to Solid.js for this, this is the standalone version of Solid.js' createMutable API,
// now made to work with Svelte 5's new runes/signals reactivity system.
const _RAW = Symbol("store-raw");
const _PROXY = Symbol("store-proxy");
const _NODE = Symbol("store-node");
const _HAS = Symbol("store-has");
const _SELF = Symbol("store-self");
const _TRACK = Symbol("store-track");
// _SELF needs a unique value to go off with, so we'll just assign with this
// uniquely increasing counter.
let self_count = 0;
function ref(init) {
let value = $state(init);
return {
get value() {
return value;
},
set value(next) {
value = next;
},
};
}
/**
* @param {any} target
* @param {typeof _NODE | typeof _HAS}
*/
function getNodes(target, symbol) {
let nodes = target[symbol];
if (!nodes) {
nodes = target[symbol] = Object.create(null);
}
return nodes;
}
function getNode(nodes, property, value) {
let state = nodes[property];
if (!state) {
state = nodes[property] = ref(value);
}
return state;
}
function trackSelf(target) {
// is there a way to check if we're currently in an effect?
getNode(getNodes(target, _NODE), _SELF, self_count).value;
}
function isWrappable(obj) {
let proto;
return (
obj != null &&
typeof obj === "object" &&
(obj[_PROXY] ||
!(proto = Object.getPrototypeOf(obj)) ||
proto === Object.prototype ||
Array.isArray(obj))
);
}
function setProperty(state, property, value, deleting) {
const prev = state[property];
const len = state.length;
let has;
if (!deleting && prev === value) {
return;
}
if (deleting) {
delete state[property];
if (prev !== undefined && (has = state[_HAS]) && (has = has[property])) {
// in Solid.js, this is set to undefined, but it only works because we can bypass the equality check.
// so set the values appropriately.
has.value = false;
}
} else {
state[property] = value;
if (prev === undefined && (has = state[_HAS]) && (has = has[property])) {
has.value = true;
}
}
const nodes = getNodes(state, _NODE);
let node;
if ((node = nodes[property])) {
node.value = value;
}
if (Array.isArray(state) && state.length !== len) {
for (let idx = state.length; idx < len; idx++) {
if ((node = nodes[i])) {
node.value = undefined;
}
}
if ((node = nodes.length)) {
node.value = state.length;
}
}
if ((node = nodes[_SELF])) {
node.value = ++self_count;
}
}
const Array_proto = Array.prototype;
const traps = {
get(target, property, receiver) {
if (property === _RAW) {
return target;
}
if (property === _PROXY) {
return receiver;
}
if (property === _TRACK) {
trackSelf(target);
return receiver;
}
const nodes = getNodes(target, _NODE);
const tracked = nodes[property];
let value = tracked ? tracked.value : target[property];
if (property === _NODE || property === _HAS || property === "__proto__") {
return value;
}
if (!tracked) {
const fn = typeof value === "function";
if (fn && value === Array_proto[property]) {
// Svelte 5's effects are async, so we don't need to put wrap this in a batch call,
// so we'll just bind the Array methods to the proxy and return it as is.
return Array_proto[property].bind(receiver);
}
// is there a way to check if we're under an effect?
const desc = Object.getOwnPropertyDescriptor(target, property);
if ((!fn || target.hasOwnProperty(property)) && !(desc && desc.get)) {
value = getNode(nodes, property, value).value;
}
}
return isWrappable(value) ? wrap(value) : value;
},
has(target, property) {
if (
property === _RAW ||
property === _PROXY ||
property === _TRACK ||
property === _NODE ||
property === _HAS ||
property === "__proto__"
) {
return true;
}
// is there a way to check if we're under an effect?
getNode(getNodes(target, _HAS), property).value;
return property in target;
},
set(target, property, value) {
// Svelte 5's effects are async, so we don't need to put setProperty under a batch call
setProperty(target, property, unwrap(value));
return true;
},
deleteProperty(target, property) {
setProperty(target, property, undefined, true);
return true;
},
ownKeys(target) {
trackSelf(target);
return Reflect.ownKeys(target);
},
};
export function unwrap(obj) {
let raw;
if ((raw = obj != null && obj[_RAW])) {
return raw;
}
return obj;
}
function wrap(obj) {
return (obj[_PROXY] ||= new Proxy(obj, traps));
}
export function reactive(obj) {
const unwrapped = unwrap(obj);
const wrapped = wrap(unwrapped);
return wrapped;
}
export const store = reactive({
deep: {
count: 123,
},
items: [{ text: "foo", done: false }],
});
export function increment() {
store.deep.count++;
}
@intrnl
Copy link
Author

intrnl commented Sep 24, 2023

Pushing items into an array no longer freezes the whole REPL, but I'm not sure why the {#each} block isn't updating as expected here:

<script>
	import { store } from './reactive.js';

	function click () {
		store.items.push({ name: 'bar' })
	}

	$effect(() => {
		console.log('effect');
		
		for (let idx = 0, len = store.items.length; idx < len; idx++) {
			const item = store.items[idx];
			console.log(item.name);
		}
	});
</script>

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

{#each store.items as item}
	<li>{item.name}</li>
{/each}

Update: this works if you add a key like (item) or (item.name) to the each block

it's because Svelte 5 doesn't try to read the length when retrieving the array, unless you define a key, where it'll read the length and the property defined in your key under the effect

image

Update 2: for array[0] = { name: 'baz' } to work where it mutates an existing array item, the effect would have to track index accesses as well, I suppose it might be worth just requiring (item) key for doing reactive arrays then? the only problem with this is that it's a caveat that ultimately has to be documented if users were to want something like this

@PatrickG
Copy link

PatrickG commented Sep 24, 2023

Nice work 👍

Shouldn't these be _RAW, _PROXY, etc. instead of $RAW, $PROXY, etc.?
Also, what is the purpose of using unwrap() here? unwrapped is not used and unwrap() has no side effects.

Edit: I've noticed when using $effect to watch the state, some parts are not rerendered. Not sure if it is a bug in this or in svelte5. REPL

@intrnl
Copy link
Author

intrnl commented Sep 24, 2023

  1. Yep, thanks for noticing, they're supposed to be underscores
  2. The result of unwrapped is supposed to go to the wrap call, it's to get the raw object out in case you passed a proxy, though I think looking at it again it's unnecessary because you could always just ask for _PROXY and it'll return the proxy (this is what Solid.js' createMutable API does, I can't quite tell why, I suppose if some other API supports asking for raw values but not the proxy itself?)
  3. Not sure why that's happening

@intrnl
Copy link
Author

intrnl commented Sep 24, 2023

Fixed #1 and #2

@intrnl
Copy link
Author

intrnl commented Sep 25, 2023

Fixed #3, it's because it wasn't properly tracking a property that hadn't been defined yet.
REPL

@PatrickG
Copy link

PatrickG commented Sep 25, 2023

❤️

This is by far the best deep reactive solution. I hope it gets implemented into the core ($reactive())

@sillvva
Copy link

sillvva commented Sep 30, 2023

Here's a playground which demonstrates the function, and a few gotchas.

@PatrickG
Copy link

PatrickG commented Dec 8, 2023

$state() is now deeply reactive 🎉

@MentalGear
Copy link

MentalGear commented Jan 29, 2024

The new deeply reactive $state is one of the main attractions of svelte 5, but it still seems willingly restricted as it is fully reactive for object literals but not instances of classes. Custom classes are a very common pattern to write clean and maintainable code, and it seems like svelte unnecessarily restricts the full power of reactivity for them.

It would be great if $state could be augmented (maybe just with an option, like $state.full() ) for that nested objects from custom classes can also gain the full svelte 5 reactive power !

Here's a sample case:

FF vs CustomClass

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment