vue icon indicating copy to clipboard operation
vue copied to clipboard

$refs should be reactive to be able to use them in computed properties

Open TheDutchCoder opened this issue 7 years ago • 52 comments

Now that refs are no longer reactive, we need to emit events and payloads from child components. This is fine and works well, but it becomes hard to maintain in the following scenario:

When you create a custom form input component, that basically wraps an input into it's own re-usable component, we can no longer access the input's value (and other props) reactively in e.g. a computed prop in the parent.

Take the following use-case:

<my-input ref="email"></my-input>
<my-input ref="password"></my-input>
<p>This is {{ isValid ? 'valid' : 'invalid' }}</p>

Previously we could create a computed prop like this:

isValid() {
  return this.$refs.email.isValid && this.$refs.password.isValid
}

Since refs don't work reactively anymore, we now need to use $emit to inform the parent of changes. However, these emits are handled by methods, so we need a separate handler per input to deal with the events.

Something like:

<my-input @valid="handleValidEmail"></my-input>
<my-input @valid="handleValidPassword"></my-input>

handleValidEmail(value) {
  this.email = value
  this.emailIsValid = true
},

handleValidPassword(value) {
  this.password = value
  this.passwordIsValid = false
}

You could refactor this a bit, but it's not a nice way of dealing with this and I'm assuming using forms is quite a common occurence for a lot of devs.

Can we think about a better solution to something like this?

TheDutchCoder avatar Oct 03 '16 22:10 TheDutchCoder

How about using one handler with multiple parameters?

<my-input @valid="handleValid"></my-input>
<my-input @valid="handleValid"></my-input>

handleValid(value, type) {
  this[type]= value
  this[type + 'Valid'] = true
},

inside components:

this.$emit('valid', value, 'email')
...

this.$emit('valid', value, 'password')

fnlctrl avatar Oct 04 '16 07:10 fnlctrl

Because you can have multiple of the same type of fields. Maybe I could use their ID's in that case instead?

TheDutchCoder avatar Oct 04 '16 11:10 TheDutchCoder

Try something like this:

<template>
  <form>
    <my-input v-model='fields.email'></my-input>
    <my-input v-model='fields.password'></my-input>
    <p>This is {{ isValid ? 'valid' : 'invalid' }}</p>
  </form>
</template>

<script>
export default {
  data () {
    return {
      fields: {
        email: { value: '', valid: false },
        password: { value: '', valid: false },
      }
    }
  },
  computed: {
    isValid () {
      return Object.values(this.fields).every(field => field.valid)
    }
  }
}
</script>

Where the input components v-model both the value of the input, and the validity status.

itsMapleLeaf avatar Oct 04 '16 12:10 itsMapleLeaf

So this is where I get confused. How does the model update the valid part? I thought it only update the value?

this.$emit({
  value: event.target.value,
  valid: someValidationMethod(this)
})

Or something? If that works it would be good (certainly not great).

The main issue I have with this, is that I need all those fields in my state, whereas previously I didn't, because I could just use the refs.

Having them in my state is a pretty major pain in the ass, because it clutters my state without a good reason.

TheDutchCoder avatar Oct 04 '16 12:10 TheDutchCoder

The main issue I have with this, is that I need all those fields in my state, whereas previously I didn't, because I could just use the refs.

@TheDutchCoder This is inconvenient indeed. :(

@yyx990803 Do you have something in mind for situations like these?

LinusBorg avatar Oct 04 '16 13:10 LinusBorg

Just to add some context, this is what I could do previously:

computed: {
  isReady() {
    return this.$refs.email.isValid && this.$refs.password.isValid
  }
}

But now I need to add additional state and a handler to deal with this:

data() {
  return {
    email: { value: '', isValid: false },
    password: { value: '', isValid: false }
  }
}

// Some method that dynamically handles input changes

// Computed prop
computed: {
  isReady() {
    return this.email.isValid && this.password.isValid
  }
}

Mainly the extra (not very useful) state and the handler are a bit of a pain, would be great if there could be some alternative to what used to be $refs that remain reactive. Not sure what the implications would be and why it was deprecated, I'm just trying to illustrate a use case when the old $refs were very useful.

TheDutchCoder avatar Oct 04 '16 14:10 TheDutchCoder

Would it be ok to rename the issue to something like: "$refs should be reactive to be able to use them in computed properties"?

LinusBorg avatar Oct 04 '16 14:10 LinusBorg

Sure go for it! Thanks for the discussion guys, it;s appreciated. Vue is an amazing framework and the open conversations around it make it only better ;)

TheDutchCoder avatar Oct 04 '16 14:10 TheDutchCoder

So this is where I get confused. How does the model update the valid part? I thought it only update the value?

If a component emits an input event while accepting a value prop, v-model will update the value in the parent. So your my-input component would need to do something like this:

<template>
  <input :value='value.value' @input="onInput($event)">
</template>

<script>
...
  props: ['value'],
...
  onInput(event) {
    this.$emit({ value: event.target.value, valid: this.someValidatorFunction() })
  }
...
</script>

I imagine there's a better way, though. Without reactive refs, that is. Perhaps using mixins, somehow...

I'm still for reactive refs, though. It's a super useful thing to have in general.

itsMapleLeaf avatar Oct 04 '16 18:10 itsMapleLeaf

Alright, at least your example works. Now with the added state, the real issue is the combination with Vuex.

Since the Vuex getters aren't yet available when the state is defined, we can't use them to populate the values.

We could use mounted or something, but again, that's quite dirty.

TheDutchCoder avatar Oct 04 '16 23:10 TheDutchCoder

Here's two repos that compare the two scenarios:

Vue1: https://jsfiddle.net/okv0rgrk/8330/ Vue2: http://jsfiddle.net/5sH6A/744/

I hope this clearly illustrates the current problem. The biggest issue right now is the fact that you can't use computed props in the child component anymore. Even this.$nextTick doesn't work in the emit event, because the v-model hasn't updated yet.

TheDutchCoder avatar Oct 07 '16 13:10 TheDutchCoder

I've also found reactive $refs to be useful in unit tests.... like so:

    var component = vm.$refs.testComponent
// .....   do something that's expected to trigger a modification of the DOM .....
    vm.$nextTick(() => {
      expect(component.value).to.equal('Something')
      done()
    })

Is there some suggested way to replace this sort of pattern? Otherwise it would be nice for this to work again.

pdemello avatar Oct 11 '16 18:10 pdemello

I have to agree with @TheDutchCoder

Using refs is an easy way for parent to read child information. Which inadvertently makes $refs seem useless without reactivity.

I commonly also use this practice, to check the validity of ref components to validate the parent component.

blake-newman avatar Oct 13 '16 08:10 blake-newman

Do you have access to my-input? Then you could create a $emit('validity-changed',this.isValid) and where you use it:

<my-input @validity-changed="isValid=$event"></my-input>
<my-input @validity-changed="isValid=$event"></my-input>
<p>This is {{ isValid ? 'valid' : 'invalid' }}</p>
// with
data: function() {return {isValid:true}}

for a normal Input I would try a computed setter:

template: "<input v-model='username'></input>",
computed: {
  username: {
    get: function() { return this.name }
    set: function(val) {
      this.valid = this.validate(val)
      if (this.valid) {
        this.name = val
      }
    }
  }
},
data: function() {
  return {name:"name", valid: true}
}

I'm using ref only for testing (checking out some component instance) or for calling methods on child components. All data stuff I do with computed, data, methods and/or watch - and I never had a problem with it.

paulpflug avatar Oct 14 '16 08:10 paulpflug

Using Vue in Meteor, I really miss this feature. But in Meteor's default frontend Blaze, $ref was available as a part of 3rd party package, not as part of the core. So if it's not possible in the core in Vue2, at least having it as a plugin would be a valuable option.

gustogummi avatar Jan 01 '17 16:01 gustogummi

This is indeed inconvenient. I'm trying to use computed properties to control the appearance of an element based on a property of two of its siblings:

<h1 v-if="showTitle">codus</h1>
<modal ref="loginModal">
  ...
</modal>
<modal ref="signupModal">
  ...
</modal>
computed: {
  showTitle() {
    return !(this.$refs.loginModal.shown || this.$refs.signupModal.shown);
  },
},

I believe this is a legitimate use case and it'd be nice to be able to do something like this.

However, it seems this.$refs is empty when the computed property is executed. As a simple test, I included the following in my computed property:

console.log(this.$refs);
setTimeout(() => console.log(this.$refs), 1000);

{} is logged, and a second later the populated refs object is logged.

Thoughts?

controversial avatar Apr 15 '17 03:04 controversial

Another use case I'm running into is testing whether a component is focused to use it to control state:

<template>
  <div class="my-component-wrapper">
    <div class="my-component" tabindex="0"></div>
  </div>
</template>
computed: {
  hasFocus() {
    return this.$refs.myElement === document.activeElement
  }
}

Right now, I'm listening for focus and blur, with separate methods to set a data property called focused (true/false) and while it works, it's a pain to implement while the above solution is where computed properties shine.

andymerskin avatar Apr 20 '17 16:04 andymerskin

Well, that use case would not even work if $refs was reactive, because no refs changed in any way, and neither did their data, only the DOM changed.

LinusBorg avatar Apr 20 '17 17:04 LinusBorg

Ah good call, technically document.activeElement is the only thing changing here. Damn. 😞

andymerskin avatar Apr 20 '17 17:04 andymerskin

Can't recall if the focus event bubbles, but if it does, register an event listener in created() and save the target element in the component's data? That would be reactive, and the computer prop could re-evaluate.

LinusBorg avatar Apr 20 '17 17:04 LinusBorg

On second thought that could lead to unnecessary rerenders.

LinusBorg avatar Apr 20 '17 17:04 LinusBorg

The simplest workaround is using $watch: https://jsfiddle.net/kenberkeley/9pn1uqam/

kenberkeley avatar Jul 10 '17 12:07 kenberkeley

I also find situations when reactive $refs is handy. Without it, you must resort to more complex solution, e.g. emit/vuex, to get the children's state.

Anyway, i think getting the children's state from parent is natural and straightforward. Without reactive feature, $refs is actually useless.

tomwang1013 avatar Aug 15 '17 07:08 tomwang1013

Related: https://forum.vuejs.org/t/split-modal-and-its-contents-slots/18338/6

As I'm thinking about it, it seems to me that if $refs isn't reactive, it's completely useless outside event handlers, isn't it?

AndreKR avatar Oct 13 '17 10:10 AndreKR

<v-disclaimer>not a guru</v-disclaimer> An awful lotta folk seem to get into trouble because they don't think view-model. Vue is "loosely inspired by MVVM". You should at least be at step 1 of the store pattern: a global variable that contains all your page state. To take your example, email and password, their current values, and, I would say, their validity, are page level state. Your inputs should be interacting directly with the state (declared in their data if they're components). And your computed prop, isValid, also just looks at the state variable. You can implement all this without ref, and if you can do without ref, you should.

I see so many folk getting into knots because they don't think about their store. Having Vue stuff directly talking to other vue stuff is an anti-pattern. Vue wants to interact with a store.

bbsimonbb avatar Oct 19 '17 09:10 bbsimonbb

But when an application starts to have multiple controls and various components, then you start have multiple stores (component's store) in order to organize the state of the application into logical reactive objects bound to their respective component. Then you need to access them from the main Vue, and then $refs reactivity start to be useful because otherwise you start using signal for top-down and signal's mess is not far away.

drzraf avatar Oct 19 '17 13:10 drzraf

Personally, my impression of $refs was that it is mostly meant to be useful as a means to get an html element reference when you absolutely need it, and these aren't reactive anyway. Using it as a way to directly access a child component's state seems like a violation of good component design. I agree that it would be convenient, but I could see myself being tempted to take advantage of that convenience in ways that would ultimately make the components harder to manage. Especially with a third party component - the component creator only makes guarantees about its props/events interface. It's ultimately no one's business how it uses its internal state, and we shouldn't make any assumptions about it remaining compatible between versions, nor should the author have to worry about others relying on its use of internal state.

maxnorth avatar Oct 27 '17 23:10 maxnorth

Of course everyone tries to design components that way. But when you have sibling components like in my case, where a button lives in a different slot of a parent, I think $refs to components actually make the code cleaner instead of cluttering it with meaningless container components which hold part of the state of their children.

AndreKR avatar Oct 28 '17 05:10 AndreKR

It's true, I see your point as well. I imagine the middle ground would be something like distinguished public and private properties/computeds/methods. That way a component's interface could remain well-defined and components could take useful references on their children.

maxnorth avatar Oct 28 '17 20:10 maxnorth

The more I think about it, the more I kinda like that idea. Could maintain compatibility by making everything public by default, but if someone wants to privatize data they could specify which fields are part of the public interface by using a property like "public": ["field1", "computed2", "method3"]. Then component refs could be proxy objects that only contain the exposed members.

maxnorth avatar Oct 28 '17 20:10 maxnorth