LegendApp / legend-state

Legend-State is a super fast and powerful state library that enables fine-grained reactivity and easy automatic persistence
https://legendapp.com/open-source/state/
MIT License
2.57k stars 76 forks source link

Inconsistent peek behavior in v3 alpha #319

Open marbemac opened 1 week ago

marbemac commented 1 week ago

Legend version 3.0.0-alpha.17. See comments below for the behavior I'm running into.

import { observable } from '@legendapp/state';

const rel$ = observable({
  to: () => ({ model: 'user' }),
  through: () => ({ model: 'membership' }),
});

/**
 * Calls to $rel.peek() returns the "to" and "through" properties as functions
 */
console.log(rel$.peek()); // { to: [Function], through: [Function] }

// Access to with get or peek, doesn't matter
console.log(rel$.to.get()); // { model: "user" }
console.log(rel$.to.peek()); // { model: "user" }

/**
 * Now calls to rel$.peek() will return the "to" property as an object
 */
console.log(rel$.peek()); // { to: { model: "user", through: [Function] } }

Furthermore, typescript thinks that rel$.peek().to.model is valid (when in reality to is a function). Is the intended behavior that peek() returns a resolved object (with computed getter functions resolved to their values) or an object with those getter functions left as functions?

jmeistrich commented 1 week ago

That is expected but I understand it's confusing. We should document it in more detail in the docs.

When you first create rel$ it's an observable with functions in it. So you should be able to peek it and get the functions you put into it. If you only use those functions as functions it should leave them as functions (as it does with through).

When you use the functions as observables, it enables the observable behavior - the value in the raw data gets updated with the latest value of the observable. You can still call it as a function, but the raw data is kept up to data with the value of the observable.

Does that make sense?

marbemac commented 1 week ago

You can still call it as a function, but the raw data is kept up to data with the value of the observable.

I might be misunderstanding - so should this consistently work?

rel$.peek().to()

It seems that if rel$.to.get/peek() has been called at any point, subsequent calls to rel$.peek().to() throw 🤔.

marbemac commented 1 week ago

The typings are also misleading - they say rel$.peek().through.model is valid and will give me a string, when in reality it returns undefined and we need to do rel$.peek().through().model instead.

It's tricky - imho it seems more intuitive for functions that serve as simple computed values to be resolved to their values on peek (since peek seems to be the "give me the plain value and it does not need to be observable" option, vs get), but I get why it's kind of weird to resolve some functions and not others.

Either way I think the most important thing is consistency - so always being able to access via function, or always via resolved prop - and type correctness so help folks navigate whatever the pattern ends up being.

jmeistrich commented 4 days ago

You can still call it as a function, but the raw data is kept up to data with the value of the observable.

I might be misunderstanding - so should this consistently work?

rel$.peek().to()

It seems that if rel$.to.get/peek() has been called at any point, subsequent calls to rel$.peek().to() throw 🤔.

You can still call the function through the observable - it holds onto the original function. So rel$.to() would work. But once the observable is activated, it updates the raw data with the value (removing the function from the raw data), so that if you get the value of an observable it contains the latest observable value. It's not possible to have both the value and the function on the raw data, so we go with the assumption that if you used it as an observable once that's how you intend to use it.

It's also impossible (AFAIK) to make the types of the raw data correct for both being functions and raw values. So I'm not sure how to do that better. If you have any ideas I'm all ears!

I think in general it's safer to call functions through the observable rather than from the peeked raw value because the observable is consistent - the original functions will always be retained in the observable. And if you have a function that you use as an observable, there's really no point in calling it as a function after that - the observable will be updating itself with the latest value of the function, so calling it again just does its computation again for no reason.

But please let me know if this behavior doesn't work well for an important use case I haven't though of!