Skip to content

Amendment proposal to Function-based Component API #63

@yyx990803

Description

@yyx990803
Member

This is a proposal for an amendment to RFC #42. I'm posting it here separately because the original thread is too long, and I want to collect feedback before updating the original RFC with this.

Please focus on discussing this amendment only. Opposition against the original RFC is out of scope for this issue.

Motivation

This update aims to address the following issues:

  1. For beginners, value() is a concept that objectively increases the learning curve compared to 2.x API.
  2. Excessive use of value() in a single-purpose component can be somewhat verbose, and it's easy to forget .value without a linter or type system.
  3. Naming of state() makes it a bit awkward since it feels natural to write const state = ... then accessing stuff as state.xxx.

Proposed Changes

1. Rename APIs:

  • state() -> reactive() (with additional APIs like isReactive and markNonReactive)
  • value() -> binding() (with additional APIs like isBinding and toBindings)

The internal package is also renamed from @vue/observer to @vue/reactivity. The idea behind the rename is that reactive() will be used as the introductory API for creating reactive state, as it aligns more with Vue 2.x current behavior, and doesn't have the annoyances of binding() (previously value()).

With reactive() now being the introductory state API, binding() is conceptually used as a way to retain reactivity when passing state around (hence the rename). These scenarios include when:

  • returning values from computed() or inject(), since they may contain primitive values, so a binding must be used to retain reactivity.
  • returning values from composition functions.
  • exposing values to the template.

2. Conventions regarding reactive vs. binding

To ease the learning curve, introductory examples will use reactive:

setup() {
  const state = reactive({
    count: 0
  })
  
  const double = computed(() => state.count * 2)
  
  function increment() {
    state.count++
  }
  
  return {
    state,
    double,
    increment
  }
}

In the template, the user would have to access the count as {{ state.count }}. This makes the template a bit more verbose, but also a bit more explicit. More importantly, this avoids the problem discussed below.

One might be tempted to do this (I myself posted a wrong example in the comments):

return {
  ...state // loses reactivity due to spread!
}

The spread would disconnect the reactivity, and mutations made to state won't trigger re-render. We should warn very explicitly about this in the docs and provide a linter rule for it.

One may wonder why binding is even needed. It is necessary for the following reasons:

  • computed and inject may return primitive values. They must be wrapped with a binding to retain reactivity.
  • extracted composition functions directly returning a reactive object also faces the problem of "lost reactivity after destructure / spread".

It is recommended to return bindings from composition functions in most cases.

toBindings helper

The toBindings helper takes an object created from reactive(), and returns a plain object where each top-level property of the original reactive object is converted into a binding. This allows us to spread it in the returned object in setup():

setup() {
  const state = reactive({
    count: 0
  })
  
  const double = computed(() => state.count * 2)
  
  function increment() {
    state.count++
  }
  
  return {
    ...toBindings(state), // retains reactivity on mutations made to `state`
    double,
    increment
  }
}

This obviously hinders the UX, but can be useful when:

  • migrating options-based component to function-based API without rewriting the template;
  • advanced use cases where the user knows what he/she is doing.

Activity

vberlier

vberlier commented on Jun 25, 2019

@vberlier

I think that's a great way to mitigate the confusion around value(). I personally didn't have a problem with value() but introducing this distinction between reactive objects and simple bindings makes a lot of sense.

tigregalis

tigregalis commented on Jun 25, 2019

@tigregalis

I was also hesitant about the use of 'value' (because it is also used as the reactive property of a now-binding) and 'state' (because it is very commonly used as a variable name), so I think this is a welcome change. Personally I'm really excited about this API.

Beyond that though, I question why there are separate toBindings and reactive functions. As an alternative, can this simply be a second argument to reactive? i.e.

  const state = reactive({
    count: 0
  }, true) // setting to true wraps each member in a binding and allows the object to be spread and retain reactivity

Is there a use-case where you would expose the whole reactive object as a binding as well as its members? i.e. why might someone do this?

  return {
    state,
    ...toBindings(state)
  }

I can't see the advantage of an extra function other than "just in case".

Another drawback which I've seen raised, which is closely related to this API (i.e. exposing the reactive variables to the render context) is that this is more verbose because of the need to 1) declare the variables and then 2) expose the variables. This is a very small thing, so it's certainly no deal-breaker, but is there a way around this?

I asked this in the other thread actually but it got lost (it relates directly to this API):

A few more questions that aren't clear to me from the RFC:

  1. How "deep" does the reactive() function actually make the object reactive? e.g. is it just one level deep (the immediate members of the object)

  2. Does the reactive() function make an array and/or the members of an array reactive (including push, pop, accessing a member, accessing the property of a member if it were an object, etc.)?

Akryum

Akryum commented on Jun 25, 2019

@Akryum
Member

@tigregalis With reactive, your data is already an object (not a primitive), so you don't need xxx.value when using it in the script.

yyx990803

yyx990803 commented on Jun 25, 2019

@yyx990803
MemberAuthor

@tigregalis if you directly create an object of bindings, you'd have to access internal values as state.count.value. That defeats the purpose.

tigregalis

tigregalis commented on Jun 25, 2019

@tigregalis

@Akryum I'm not sure what you mean, sorry. My comment was more around the ergonomics of spreading and exposing reactive state to the render context.

CyberAP

CyberAP commented on Jun 25, 2019

@CyberAP
Contributor

Wouldn't toBindings completely eliminate the need for binding function and leave us with just reactive?

Also, I personally find it very frustracting that you have to remember which one is which and always keep that in mind when working with reactive values. React has solved this very elegantly with two exports: getter and a setter. I'd much rather have this, then constantly check if I'm working with a binding or with a reactive object.

const [counter, setCounter] = toBinding(0);

const increment = () => setCounter(++counter);

return { counter, increment };

counter in that case is an object with a valueOf property, that is reactive.

yyx990803

yyx990803 commented on Jun 25, 2019

@yyx990803
MemberAuthor

@CyberAP

The need for binding() is already mentioned:

  • returning values from computed() or inject(), since they may contain primitive values, so a binding must be used to retain reactivity.
  • returning values from composition functions.
  • exposing values to the template.

In your example, counter is a plain value and cannot retain reactivity when returned. This would only work if the whole setup is invoked on every render - which is exactly what this RFC is avoiding.

tigregalis

tigregalis commented on Jun 25, 2019

@tigregalis

@yyx990803 I haven't worked with proxies so excuse my ignorance, but is it possible to forward the get/set of state.count to state.count.value?

yyx990803

yyx990803 commented on Jun 25, 2019

@yyx990803
MemberAuthor

@tigregalis spread internally uses get. So when forwarding you break the spread (and thus disconnect the reactivity)

CyberAP

CyberAP commented on Jun 25, 2019

@CyberAP
Contributor

Yes, I've forgotten to add that counter is an object with a valueOf property that provides an actual reactive value. @LinusBorg said that it has been discussed internally but there's been no feedback on that proposal since.

Maybe with a valueOf we can somewhat mitigate the need to use .value to access reactive value?

const counter = binding(0);

console.log(counter);  // uses valueOf()

const increment = () => counter.value++; // as initially proposed

return { counter };
yyx990803

yyx990803 commented on Jun 25, 2019

@yyx990803
MemberAuthor

@CyberAP binding may also need to contain non-primitive values so I don't think valueOf would cover all use cases. It's also too implicit - I'm afraid it will lead to more confusions than simplification.

dealloc

dealloc commented on Jun 25, 2019

@dealloc

What if this

  return {
    ...toBindings(state), // retains reactivity on mutations made to `state`
    double,
    increment
  }

would automatically be done if you did this:

  return {
    state, // retains reactivity on mutations made to `state`
    double,
    increment
  }

ie. if you directly set state in the object

yyx990803

yyx990803 commented on Jun 25, 2019

@yyx990803
MemberAuthor

@dealloc we did discuss that option internally, but overall we want to avoid "magic keys" and be as explicit as possible.

CyberAP

CyberAP commented on Jun 25, 2019

@CyberAP
Contributor

@yyx990803 could you please elaborate more on the non-primitive values in binding? What's the usecase for this when we have reactive? Except for use in an object spread within toBinding helper.

154 remaining items

tmlangley

tmlangley commented on Jul 8, 2019

@tmlangley

Hey @smolinari - Thanks for the clarification! I definitely misunderstood.

For the namespacing, I'm referencing a comment by @tigregalis where they point out that you could do something like this...

const visits = reactive({
  count: 0,
  latest: null
})

const upvotes = reactive({
  count: 0,
  upvoters: []
})

return {
  ...toBindings(visits),
  ...toBindings(upvotes)
}

This might not seem like a common case but I would be tempted to do it so that data is grouped logically.

That being said, maybe it can be taken care of with documentation.

smolinari

smolinari commented on Jul 8, 2019

@smolinari
Contributor

From my understanding, the toBindings helper method would only be needed to return bindings of a more generic "state" object, not objects with specific purposes. So, specific objects would be returned as they are. So in your example:

const visits = reactive({
  count: 0,
  latest: null
})

const upvotes = reactive({
  count: 0,
  upvoters: []
})
// would be returned as objects, not as bindings.
return {
  visits,
  upvotes
}

Which means, in the template you would be using them as {{ visits.count }}, {{ visits.latest }}, {{ upvotes.upvoters }}, etc.

In order to use toBindings you need a few properties within your component, which would normally be simple primitives or an array. Instead of using binding or value to create each reactive variable, you put them in a generic "state" object.

const state = reactive({
  count: 0,
  text: '',
  foo: '',
  bar: []
})

And you use this object throughout your component like so.

const double = computed(() => state.count * 2)

In other words, as an object, you can use state.count. With a binding, you'd have this....

let count = binding(0)
const double = computed(() => count.value * 2)

And here you can see, we need to add the .value property to get the value. The fact count is some generic state variable isn't quite as clear either. I'm certain something like using a generic "state" object as a container for unrelated data will end up a best practice.

Also with bindings, you'd have to name all the bindings singularly in the return object, along with the boilerplate of having to write out all of the binding assignments.

setup () {

...
  return {
    count,
    text,
    foo,
    bar
  }
}

Whereas with toBindings all you'd have to do is

setup () {

...
  return {
    toBindings(state)
  }
}

And you'd have {{ count }}, {{ text }}, {{ foo }} and {{ bar }} available in your template.

I hope that is making sense. 😊

Scott

skyrpex

skyrpex commented on Jul 8, 2019

@skyrpex

Whereas with toBindings all you'd have to do is

setup () {

...
  return {
    toBindings(state)
  }
}

I think it should be:

setup () {
   return {
        ...toBindings(state)
    }
}
thenikso

thenikso commented on Jul 10, 2019

@thenikso

I fully agree that having "magic" names is not the best thing but I am going to suggest this anyway :D

I am both inspired by the new SwiftUI thingy and in retaining the data name Vue users are used to. So, what if:

state()/reactive() -> data()

Where the returned object has a special getter for properties starting with $ so that:

setup() {
  const state = data({ count: 0 });

  const countBinding = state.$count; // access to a property binding
  // equivalent
  const countBinding = state.$.count;

  return {
    ...state.$, // access to a binding version of the object
  };
}

value()/binding() no longer needed

As you'd instead write:

const countBinding = data({ count: 0 }).$count;

I can already see issues with the approach I am proposing such as using keys starting with $ in the initialization object or forgetting the $ when accessing a binding.

To answer the original question I think that reactive/binding are better names than state/value.

Timkor

Timkor commented on Jul 24, 2019

@Timkor

Why not just:

const state = useState({
   count: 1
});

And:

const value = useValue(1);

Same for computed. Looks for me more inline with hooks and it also does prevent variable name clashing.

o-w-o

o-w-o commented on Aug 5, 2019

@o-w-o

convention: access or tranfer data can only by observable data itself methods.

setup() {
    const state = reactive({
      num: 0,
      arr: ['a', 'b', 'c'],
      str: 'abc',
    })

    const double = computed(() => state.count * 2)

    function increment() {
      state.num++
    }

    return {
      ...state.$pick(["num"]) // retains reactivity on mutations made to `state`
      double,
      increment
    }

    return {
      ...state.$pickAll()
    }

    return {
      ...state.$omit(["arr"])
    }
  }
smolinari

smolinari commented on Aug 5, 2019

@smolinari
Contributor

@o-w-o - Isn't the spread on the state.$pick superfluous?

I also believe you are missing the greater picture. A state variable would only be needed for any kind of general data within the component. Everything else would be defined in their own reactive objects in their own modules and "imported" in and "used" in the setup function. It would totally defeat the nature of this new API to define all state in one object in the setup function (which is the issue of the options object) and of course, only if that is what you were thinking.

Scott

luxaritas

luxaritas commented on Aug 6, 2019

@luxaritas

As a belated note per #63 (comment)

I'm a proponent for always returning a reactive.

The potential issues Evan mentioned were

  • avoid spreading the object (loses reactivity)
  • avoid destructuring the object (also loses reactivity)
  • always nest property access in template
  • use toBindings if they want to avoid nesting

I'd think these would actually be best practices. If some state is coming from a composition function, IMO it's more readable to always keep it "grouped"/"scoped", so that you always know where a given property is coming from (as opposed to having to reference the destructure statement). You could think of it similarly to following the principle of preferring composition over inheritance.

When I posted #63 (comment) , I didn't realize that you could put computeds/methods/etc on reactive/state. This actually resolves a big readability issue I had which I discussed in #42 (comment). Knowing this, I might actually be willing to drop class API for the function API - and I'm wondering if many "object API proponents" would feel the same way (though not all of them, I'm sure).

setup() {
  const state = reactive({
    count: 0,
    double: computed(() => state.count * 2),
    increment: () => state.count++
  });
  return state;
}

feels significantly simpler and easier to reason about from the perspective of "what's in my state" than

setup() {
  const state = reactive({
    count: 0
  })
  
  const double = computed(() => state.count * 2)
  
  function increment() {
    state.count++
  }
  
  return {
    ...toBindings(state), // retains reactivity on mutations made to `state`
    double,
    increment
  }
}
luxaritas

luxaritas commented on Aug 6, 2019

@luxaritas

For clarity

I'm a proponent for always returning a reactive.

My intent with that isn't necessarily "I think returning a reactive should be the only option", but "I think returning a reactive should be the preferred option, and the one used in tutorials, etc" (particularly because it appears to me to be more beginner friendly due to its simplicity). If it's the only option I won't personally complain, but I'm sure others prefer the current proposed approach.

tiepnguyen

tiepnguyen commented on Aug 7, 2019

@tiepnguyen

Can I expect createComponent to take first argument as component name if it is a string?

import { createComponent } from 'vue'

export default createComponent('MyComponent', (props: {msg: string}) => {
  ...
  return () => <SomeTSX/>
})

Because I prefer TypeScript-only props, and mainly use TSX / render function, I don't need any other options but setup(), however I still need component name (for devtool debug purpose?)

yyx990803

yyx990803 commented on Aug 17, 2019

@yyx990803
MemberAuthor

Closing in favor of #78

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @rbiggs@yyx990803@thecrypticace@doncatnip@skyrpex

        Issue actions

          Amendment proposal to Function-based Component API · Issue #63 · vuejs/rfcs