Observable - syntax #6
Replies: 13 comments
-
Hi @redblobgames! Yes, the value getter / setter syntax was one of my considerations for my API, similar to VanJS, Vue, and Preact. I ultimately decided on value for the getter and update() for the setter as the syntax got very tricky when it comes to nested reactivity. For example, stuff like I ultimately decided on just
I think the flexibility of allowing people different ways to mutate / change state and have the program "just work" is better than having to learn new conventions. I'm generally optimizing for junior developer experience as I work with those folks a lot. |
Beta Was this translation helpful? Give feedback.
-
BTW, Vue's const user = reactive(…)
if (user.name == 'John') {
user.name = 'Jane';
user.age = 31;
user.address.street = '456 Elm St';
user.address.city = 'Othertown';
user.address.country = 'Canada';
user.address.postalCode = '67890';
user.address.coordinates.lat = '51.5074';
user.address.coordinates.long = '0.1278';
} else {
user.name = 'John';
user.age = 30;
user.address.street = '123 Main St';
user.address.city = 'Anytown';
user.address.country = 'USA';
user.address.postalCode = '12345';
user.address.coordinates.lat = '40.7128';
user.address.coordinates.long = '74.0060';
} [Side note: I would prefer that Vue's ref/reactive were merged though, as I'd prefer just one type of observable than two, but … they had to split it because they didn't want to use
I think that's a great design tradeoff. Vue and React seem to be moving away from that, and I've been looking for something simpler. |
Beta Was this translation helpful? Give feedback.
-
Thanks for that! I'll take a closer look into Vue's implementation and see if there's something we can use. |
Beta Was this translation helpful? Give feedback.
-
It'd be cool if cami this.location.update(value => 'airplane'); // top level
this.user.update(value => { // nested
value.status = 'traveling';
});
// two different update() calls vs vue const location = ref('') // top level uses ref(), can't use reactive()
location.value = 'airplane' // need to use .value with ref()
const user = reactive({status: ''}) // nested uses ref() _or_ reactive()
user.status = 'traveling' // don't use .value with reactive() vs what I would consider the most natural: // 'this' is a reactive() that has everything, so no need for .value or .update
this.location = 'airplane'; // top level
this.user.status = 'traveling'; // nested However I haven't dug into ES6 Proxy to figure out whether this is possible… |
Beta Was this translation helpful? Give feedback.
-
@redblobgames it's possible with ES6 Proxies. Here's a spike i had: Below defines // Reactivity with Proxies
// very similar to Vue's
function isObject(obj) {
return obj !== null && typeof obj === 'object';
}
function createObservable(obj) {
if (!isObject(obj)) {
return obj;
}
// Recursively make all properties of the object observable
for (const key in obj) {
obj[key] = createObservable(obj[key]);
}
return new Proxy(obj, {
get(target, key, receiver) {
// Track the property access
track(target, key);
// Return the actual value
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
// If the new value is an object, make it observable
if (isObject(value)) {
value = createObservable(value);
}
// Save the old value for comparison
const oldValue = target[key];
// Set the new value
const result = Reflect.set(target, key, value, receiver);
// If the value has changed, trigger the effects
if (oldValue !== value) {
trigger(target, key);
}
return result;
}
});
}
let currentEffect = null;
const depsMap = new WeakMap();
// Function to track property access
function track(target, key) {
if (currentEffect) {
let deps = depsMap.get(target);
if (!deps) {
deps = new Map();
depsMap.set(target, deps);
}
let dep = deps.get(key);
if (!dep) {
dep = new Set();
deps.set(key, dep);
}
dep.add(currentEffect);
}
}
// Function to trigger effects
function trigger(target, key) {
const deps = depsMap.get(target);
if (deps) {
const dep = deps.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}
}
// Function to create an effect
function effect(eff) {
currentEffect = eff;
eff();
currentEffect = null;
} Here's the usage code. The effect is just a simple console.log. const data = createObservable({
location: 'airplane',
user: {
status: 'traveling'
}
});
effect(() => {
console.log(`Location: ${data.location}, User Status: ${data.user.status}`);
});
data.location = 'train';
data.user.status = 'working'; The constraint with this design is that it only works with objects, if you pass primitives such as strings, numbers, etc, it does not work as javascript primitives are immutable (reactivity can only work with mutable structures like objects). I think Vue's new In Vue, this is how normal count & nested object reactivity looks like: Vue count: // vue
const count = ref(0)
console.log(count) // { value: 0 }
console.log(count.value) // 0
count.value++
console.log(count.value) // 1 Vue deep reactivity: // vue
import { ref } from 'vue'
const obj = ref({
nested: { count: 0 },
arr: ['foo', 'bar']
})
function mutateDeeply() {
// these will work as expected.
obj.value.nested.count++
obj.value.arr.push('baz')
} And this is how it looks like for Cami (FYI - I plan to release the underlying Cami counter: this.count = this.observable(0)
this.count.update(value => value + 1); Cami deep reactivity: this.obj = this.observable({
nested: { count: 0 },
arr: ['foo', 'bar']
}); this.obj.update(value => {
value.nested.count++;
value.arr.push('baz');
}); The Cami syntax is slightly less natural, but it's is immutable under the hood with immer. It's helpful with undo / redo features and time-travelling through states (haven't exposed the time-travelling features yet, but in the works. Mostly a devtools thing). FWIW, I think Svelte (before runes) has the most natural API: Svelte counter: // svelte
let count = 0;
$: console.log(count); // 0
count++; // 1 Svelte deep reactivity: // svelte
let obj = {
nested: { count: 0 },
arr: ['foo', 'bar']
};
function mutateDeeply() {
// these will work as expected.
obj.nested.count++;
obj.arr.push('baz');
obj = obj; // This line is necessary to notify Svelte about the change
}
$: console.log(obj); However, Svelte's ergonomics come at a cost -- they did this at compile-time. Considering the project's "no build" goal, that highly ergonomic API isn't possible as we do things at runtime. That said, it's interesting to note that Svelte is now moving away from this and into signal-style API called runes. I believe one motivation for changing this is due to maintainability concerns, as the "it looks like a variable" type of API introduces some readability problems the larger the project is. In case you're curious, Vue has an interesting section on API trade-offs here. |
Beta Was this translation helpful? Give feedback.
-
I thought about this a bit more and I'm warming up to the idea of having a more granular state change API to make common tasks more convenient. For example: Before: this.count.update(value => value + 1);
this.emailError.update(() => 'Please enter a valid email address.'); After: this.count.value++
this.emailError.value = 'Please enter a valid email address.'; But beyond that, we can also use object methods / array methods for assigning as well: Before: this.user.update(value => {
value.name = this.initialUser.name;
value.age = this.initialUser.age;
value.email = this.initialUser.email;
});
this.tasks.update(tasks => {
tasks.push(task);
}); After: this.user.assign(this.initialUser);
this.tasks.push(task); I actually just pushed a small subset so far to the latest CDN (it doesn't affect existing API). Turns out it wasn't such a big deal to implement as these all used |
Beta Was this translation helpful? Give feedback.
-
I think one possible advantage cami has over vue is that because you provide a ReactiveElement class, you could wrap the entire class object inside observable(). Then I admit I may be overly optimistic about this. And there's the downside that there's no way to put non-reactive data into the ReactiveElement. |
Beta Was this translation helpful? Give feedback.
-
That's super interesting -- just had to try it out :) I spiked a very magical API that replaced // the "magical" api. not feasible. also lots of overhead and with the cons you mentioned (i.e. no way to put non-reactive data)
class CounterElement {
count = 0
template() {
return html`
<button @click=${() => this.count.value--}>-</button>
<button @click=${() => this.count.value++}>+</button>
<div>Count: ${this.count.value}</div>
`;
}
}
cami.define('counter-component', CounterElement); However, there's a middle ground that's not so bad I think, which is inspired by MobX's class CounterElement extends ReactiveElement {
count = 0;
constructor() {
super();
this.define({
observables: ['count']
});
}
template() {
return html`
<button @click=${() => this.count.value--}>-</button>
<button @click=${() => this.count.value++}>+</button>
<div>Count: ${this.count.value}</div>
`;
}
}
customElements.define('counter-component', CounterElement); It's arguably more readable in the long run when you have lots of computed / observable definitions Before: class CounterElement extends ReactiveElement {
count = this.observable(0);
countSquared = this.computed(() => this.count.value * this.count.value);
countCubed = this.computed(() => this.countSquared.value * this.count.value);
countSqrt = this.computed(() => Math.sqrt(this.count.value));
// ... other code After: class CounterElement extends ReactiveElement {
count = 0;
countSquared = () => this.count.value * this.count.value;
countCubed = () => this.countSquared.value * this.count.value;
countSqrt = () => Math.sqrt(this.count.value);
// ... other code
constructor() {
super();
this.define({
observables: ['count'],
computed: ['countSquared', 'countCubed', 'countQuadrupled', 'countPlusRandom', 'countSqrt']
})
// ... rest While similar to MobX, it's still better DX I think as we don't have to define actions, and we don't have to add observer / autorun pass a function component to it like MobX and React. Since we're working with a |
Beta Was this translation helpful? Give feedback.
-
Oh that's really interesting! I hadn't thought about custom elements not going through the proxy. I knew about MobX but had never looked at it before. Both makeObservable and makeAutoObservable look interesting |
Beta Was this translation helpful? Give feedback.
-
Dug a little deeper today and it looks like we can do away with We can do a MobX-like convention in the following way (i.e. class CounterElement extends ReactiveElement {
count = 0;
constructor() {
super();
this.setup({
observables: ['count'],
computed: ['countSquared', 'countCubed', 'countQuadrupled', 'countPlusRandom', 'countSqrt']
})
}
get countSquared() {
return this.count * this.count;
} Cami should have other goodies as well like getting values from HTML attributes (just need to define a parse function). <my-component
todos='{"data": ["Buy milk", "Buy eggs", "Buy bread"]}'
></my-component>
<!-- other stuff -->
<script type="module">
const { html, ReactiveElement } = cami;
class MyComponent extends ReactiveElement {
todos = []
constructor() {
super();
this.setup({
observables: ['todos'],
attributes: {
todos: (v) => JSON.parse(v).data
}
});
} Overall, the future API should feel very natural (see playlist example below). Just "mutate" and it will automatically render. There are a few "gotchas" as it's immutable under the hood, but it only affects nested key updates in large object (for which, the class PlaylistElement extends ReactiveElement {
playlist = [];
constructor() {
super();
this.setup({
observables: ['playlist'],
});
}
addSong(song) {
this.playlist.push(song);
}
removeSong(index) {
this.playlist.splice(index, 1);
}
moveSongUp(index) {
if (index > 0) {
const songToMoveUp = this.playlist[index];
const songAbove = this.playlist[index - 1];
this.playlist.splice(index - 1, 2, songToMoveUp, songAbove);
}
}
moveSongDown(index) {
if (index < this.playlist.length - 1) {
const songToMoveDown = this.playlist[index];
const songBelow = this.playlist[index + 1];
this.playlist.splice(index, 2, songBelow, songToMoveDown);
}
}
sortSongs() {
this.playlist.sort();
}
reverseSongs() {
this.playlist.reverse();
} The eventual trick was to use getter / setter traps (like Vue's // snippet
_handleObjectOrArray(context, key, observable, isAttribute = false) {
const proxy = this._observableProxy(observable);
Object.defineProperty(context, key, {
get: () => proxy,
set: newValue => {
observable.update(() => newValue);
if (isAttribute) {
this.setAttribute(key, newValue);
}
}
});
}
_handleNonObject(context, key, observable, isAttribute = false) {
Object.defineProperty(context, key, {
get: () => observable.value,
set: newValue => {
observable.update(() => newValue);
if (isAttribute) {
this.setAttribute(key, newValue);
}
}
});
} I haven't released the above yet (will sleep on it). If you don't mind, I'll add you as a co-author in v0.1.0. Your questions / comments have been helpful :) |
Beta Was this translation helpful? Give feedback.
-
Ahh, cool! So the Proxy can't wrap a custom element, but you can insert getter/setter traps for the top level, and use Proxy for the levels underneath? That seems like it should work. I thought a few nights of sleep would give me more ideas but I don't have a lot to offer right now… |
Beta Was this translation helpful? Give feedback.
-
Yup, you got that right! |
Beta Was this translation helpful? Give feedback.
-
For those who may be following this thread, we've made another simplification where you don't need to define the constructor anymore, leading to this simpler API:
More details in this announcement thread. |
Beta Was this translation helpful? Give feedback.
-
Do you have any thoughts on
vs
(as used in Vue and Preact) I think the advantage of the function is that you might want to run it later. So after that statement, this.count might still have the old value. But the advantage of the assignment is that it looks like ordinary variable assignment, and you expect that after that statement, this.count will have the new value. That seems simpler to reason about.
Beta Was this translation helpful? Give feedback.
All reactions