thefrontside / microstates

Composable state primitives for JavaScript
1.31k stars 53 forks source link

Array microstate functions vs. Query functions #264

Closed epikhighs closed 6 years ago

epikhighs commented 6 years ago

I was wondering what the difference is b/t using an Array microstate's map, filter and reduce functions VS. the map, filter, reduce found in the Query class? I found this snippet of info (https://github.com/microstates/microstates.js#mapmicrostate-fn-microstate), but I don't understand why I couldn't just Array.map and get the same result?

From the TodoMVC project, the Array.filter and Query.filter seem to be used interchangeably.

https://github.com/microstates/todomvc/blob/master/todomvc.js#L35

https://github.com/microstates/todomvc/blob/master/todomvc.js#L96

taras commented 6 years ago

That's a great question.

Methods on the Microstate are transitions, when invoked they'll create a new microstate with the result of that operation.

import { map, create } from 'microstates';

let arr = create(Array, [1, 2, 3]);

arr.map(i => i.increment());
// Microstate<Array>[2, 3, 4]

In this example we will create a new microstate with i => i.increment() operation applied to each element in the array. It'll return a new microstate with the result of this operation.

Query functions operate on collections of microstates but don't transition the microstate itself.

import { map, create } from 'microstates';

let arr = create(Array, [1, 2, 3]);

<ul>
 {map(arr, i => <li key={i.state}>{i.state}</li>)}
</ul>

In this example, we're mapping over array of microstates and creating components. We don't want to cause transitions while trying to create components, so we use the query.

This difference becomes even more important when used in a Store. Transitions in a store will cause a re-render cycle, so you have make sure that you use queries.

Let me know if you have more questions. Always happy to answer them.

cowboyd commented 6 years ago

This is a very natural source of initial confusion, and is probably worth addressing explicitly in the README.

epikhighs commented 6 years ago

Hi guys. Thanks for the clarification.

Methods on the Microstate are transitions

I'm guessing this means the "methods on the Array microstate are transitions"?

Generally speaking, methods in a given microstate aren't required to be transitions right? So in the todoMVC example referenced in the OP, both completed() and clearCompleted() are methods on a microstate, but clearly completed() is not a transition. Or do ES5 getters have some special treatment/usage in microstates?

get completed() {
    return filter(this.todos, todo => todo.state.completed);
  }
clearCompleted() {
  return this.todos.filter(({ completed }) => !completed.state);
}

On a related note, completed() feels like a way to compute a derived microstate that doesn't change/transition the microstate. Is this an intended usage for microstates to "normalize" the data? I'm drawing an analog to how state selectors are used in redux (via the reselect library) where you can remove duplicated data from the redux state by using state selectors to derive data.

Query functions operate on collections of microstates but don't transition the microstate itself.

That makes more sense now, but I think the example of using Query.map for the component use case could use improvement. The part that is still unclear is that I think you could have the same resultant outcome using Array.map. For example, the microstate transitions when using Array.map(), but the transitioned microstate needn't be set back into the component local state right? So the example could just use Array.map and do nothing w/ the transitioned microstate and it'd be the same as if using Query.map, right?

import { map, create } from 'microstates';

let arr = create(Array, [1, 2, 3]);

<ul>
 {arr.map(i => <li key={i.state}>{i.state}</li>)}
</ul>
// now, don't do anything w/ arr and it'd be the same net effect as using query.map right?

I guess the main concern could be (please correct me)

  1. performance (where the assumption is that causing a transition might be more memory or cpu intensive than using a Query)

  2. The readability or intent of the code could be misinterpreted b/c a transition occurs, but is effectively a noop b/c nothing is done w/ the transitioned microstate

let todo = create(TodoMVC, {
  todos: [
    { id: 1, text: "one", completed: false },
    { id: 2, text: "two", completed: true }
  ]
});

const todoB = todo.todos.map(x => x); // interesting to note that this is indeed a transition
console.log(todo); //todo remains unchanged
console.log(todo !== todoB); // true
taras commented 6 years ago

Generally speaking, methods in a given microstate aren't required to be transitions right?"

Methods are transitions on any microstate.

Or do ES5 getters have some special treatment/usage in microstates?

Getters actually do not have a special treatment in microstates, but they have one special behaviour when the microstate is wrapped in a Store.

Getters on Microstates are regular getters. As ES5 getters they allow to perform a computation and present the return value as the value of the property. When wrapped in a Store, getters get cached so that any getter is only ever executed one time for any microstate wrapped in a store.

So in the todoMVC example referenced in the OP, both completed() and clearCompleted() are methods on a microstate, but clearly completed() is not a transition.

  get completed() {
    return filter(this.todos, todo => todo.state.completed);
  }

  clearCompleted() {
    return this.todos.filter(({ completed }) => !completed.state);
  }

get completed() is a getter that evaluates to a query. clearCompleted() is a transition that that returns a new microstates with the result of the transition.

On a related note, completed() feels like a way to compute a derived microstate that doesn't change/transition the microstate.

This is exactly what they are. I couldn't have said it better myself.

taras commented 6 years ago

Is this an intended usage for microstates to "normalize" the data?

We imagine a future when there will be a higher order type that allows data normalization. We'll be able to reuse it and hopefully never do manual data normalization.

I'm drawing an analog to how state selectors are used in redux (via the reselect library) where you can remove duplicated data from the redux state by using state selectors to derive data.

Selectors and getters have a similar purpose. They have a slightly different performance profile at the moment because selectors are cached based on dependant values. In the Store, microstates' getters are cached per microstate - rather than per consumed property. We'll probably eventually make getters in the Store cached with consumed property as the key, but we're not doing that now.

the example of using Query.map for the component use case could use improvement.

Agree. We need to build a proper documentation site. It's on the roadmap but didn't get around to it yet. We'll have one by 1.0 for sure - would love help if you're interested in helping.

The part that is still unclear is that I think you could have the same resultant outcome using Array.map. For example, the microstate transitions when using Array.map(), but the transitioned microstate needn't be set back into the component local state right? So the example could just use Array.map and do nothing w/ the transitioned microstate and it'd be the same as if using Query.map, right?

Right, this could work with microsates that are not wrapped in a Store because they don't have any side effects. If you call a transition, you'll just get a new microstate. When you're using the store, transitions will cause a side effect which results in next state emerging where you created the Store.

You want to keep your renders pure, so use the queries to extract microsates from a microstate and perform transitions on events.

performance (where the assumption is that causing a transition might be more memory or cpu intensive than using a Query)

Queries should be lighter because they're just reading from the microstates tree. Transitions (side effects aside) do more work to provide convenient/consistent APIs inside of transitions. They also re-concile no-op operations to emit new state. So there is more work being done in a Transition for sure. This is part of the reason why they should never happen in a render cycle.

The readability or intent of the code could be misinterpreted b/c a transition occurs, but is effectively a noop b/c nothing is done w/ the transitioned microstate

Yeah, this would be very weird. I wouldn't want that kind of ambiguity.

epikhighs commented 6 years ago

Thanks for the detailed and thorough responses. I appreciate it.

One last thing, @cowboyd referred to Store in separate issue (https://github.com/microstates/microstates.js/issues/266), but I did not realize you mentioned Store so much in your response.

From what I gather, Store is an alias for Identity and it's somewhat related to RxJS/Observables (which I don't know much about), but looking at the Identity implementation leaves me thinking it's not the same as a simple identity function x => x 😄 So I'm confused about the origins of the name Store and Identity, but also, it's intended usage. Then, I think your references to "wrapping a microstate in a store" will make more sense.

taras commented 6 years ago

From what I gather, Store is an alias for Identity and it's somewhat related to RxJS/Observables

Store is an alias for Identity. We'll need to settle on one naming convention, but we haven't pulled the trigger on which yet. Store has a bunch of optimizations that are particularly useful in environments where state is created at the top of the component tree and pushed down through props.

One of the challenges that Store overcomes is that invocation of a transition needs to emit the next state at the top of the component tree not at the call-site. You can see this clearly when looking at how you'd use Microstates in React.

import React from 'react';
import { create, Store } from 'microstates';

let initial = create(Number, 42);

class App extends React.Component {
  state = {
    // regardless of where in the transition is invoked, 
    // next state needs to be set here
    $: Store(initial, $ => this.setState({ $ }))
  }

  render() {
    return (
     <button onClick={() => {
       // getting next microstate here is not useful
       // we need it at the top where the store is created
       this.state.$.increment();
     }}>Increment ({ this.state.$.state })</button>
    );
  } 
}

Consider that the microstate can be huge and transitions might be invoked 10 levels deep in the component tree. Regardless, next microstate needs to be set at the top of the component tree.

It's tangentially related to Observables because Store is used internally to convert microstates into a stream of microstates. You can turn any Microstate into an Observable using Observable.from(microstate).subscribe(next => next). Observable subscribe takes a function with same signature as Store's callback.

Store has other optimizations designed for environments that optimize re-render based on reference. Microstates transitions always return new microstate objects, but Store will ensure that any microstate that is not changed is reused between transitions. This makes it easy to optimize component rendering by checking by reference. If reference is different, you can expect that value in the microstate has changed.

epikhighs commented 6 years ago

Got it. Makes sense. Thank you so much.