Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fully controlled form elements #188

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions active-rfcs/0000-fully-controlled-form-elements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
- Start Date: (fill me in with today's date, YYYY-MM-DD)
- Target Major Version: 3.x
- Reference Issues:
- Implementation PR:

# Summary

Change `v-model` behaviour to be in full control over form elements' value.

# Basic example

Consider such a component:

```html
<template>
<input v-model="controlledModel">
</template>

<script>
export default {
name: 'ControlledInput',
data() {
return {
value: null,
}
},
computed: {
controlledModel: {
get() { return this.value },
set(value) {
this.value = value[0] || null
},
}
}
}
</script>
```

Currently Vue will render anything that user enters in the `<input>` even though state value is different from what the user actually sees.

After the change user only sees the first character, which perfectly corresponds to the actual state.

# Motivation

The main goal of any state-centric UI library or framework is solving the problem of UI and state synchronization. This is commonly achieved through bindings for various UI parts.

Bindings could be expressed in form of one of the following:

- **One-way binding** – UI reacts to bound state value.

A Vue version of this is called `v-bind`. User input is handled by event listeners.

- **Two-way binding** – UI reacts to bound state value, but state value reacts to input from UI as well.

In Vue this is called `v-model`. User input is handled by the model.

But here is the problem: what should happen when input from UI is transformed by the model or an event listener?

Moreover, what should happen when there's no event listener at all in a one-way binding case? Should any input be prevented then?

There are two ways to deal with this kind of problem: *Controlled* and *Uncontrolled* bindings.

In Controlled approach UI always corresponds to state whenever a state to UI binding is present.
In Uncontrolled approach UI is non-blocking and user input can break out of state synchronization.

A common example is an `<input>` that has state value bound to its DOM value.
An Uncontrolled version of the `<input>` would allow for any user input no matter what the actual state value is.
A Controlled version would not allow user input that doesn't correspond to final state value.

## Current state

Vue implements uncontrolled approach for form elements bindings (i.e. elements that can accept user input) in options API.

### Inconsistency with Composition API

Current `v-model` behaviour is inconsistent between options API or Composition API.

The following code will produce a fully controlled `v-model` in Composition API:

```html
<template>
<input v-model="model">
</template>

<script>
import { ref } from 'vue'

export default {
setup() {
const message = ref(null)

return {
get model() { return message.value },
set model(value) {
if (value.length > 2) return
message.value = value
}
}
}
}
</script>
```

While this would not:

```html
<template>
<input v-model="model">
</template>

<script>
import { ref, computed } from 'vue'

export default {
setup() {
const message = ref(null)

const model = computed({
get() { return message.value },
set(value) {
if (value.length > 2) return
message.value = value
}
})

return {
model
}
}
}
</script>
```

This RFC intends to align current options API and Composition API behaviour to a consisted one.

### Comparison with other frameworks

| Binding\Library | Vue | React | Svelte | Angular 2+ |
|-----------------|--------------|----------------|--------------|--------------|
| Two-way binding | Uncontrolled | Not applicable | Controlled | Uncontrolled |
| One-way binding | Uncontrolled | Controlled | Uncontrolled | Uncontrolled |

Vue follows Angular approach to controlled bindings, while Svelte provides a nice balance between strictly controlled and uncontrolled behaviour.

[Compare Vue and Svelte model transformations behaviour](https://zkerd.sse.codesandbox.io/)

Examples of two-way binding:

```html
<input v-model="someValue">
```
```html
<!-- svelte -->
<input bind:value="someValue">
```
```html
<!-- angular -->
<input [(ngModel)]="someValue">
```

Example of one-way binding:

```html
<input :value="someValue">
```

This is considered a one-way binding as well:

```html
<input :value="someValue" @input="onInput">
```

## Problems of uncontrolled approach

Uncontrolled approach requires special handling when it comes to state synchronization.
Right now it's possible to force a controlled mode via `this.$forceUpdate()`, but this creates a couple of problems:

- Developer has to be aware of this behaviour, know about `$forceUpdate` and how it actually works
- This fix has to be applied for every uncontrolled model that has to be forced into controlled mode

Developers unaware of `$forceUpdate` fix could create their own solutions to the problem, in particular prevent native `keyup` event for validation, which is not sustainable in a long run.

And most importantly, it's an expected behaviour for `v-model`. When a two-way binding is created it is expected that the model is in a full control over the value the UI displays.

## Benefits of suggested mixed approach

Fully controlled bindings have their downsides as well:

- Clumsy uncontrolled element design (via refs in React for example). We'd like to preserve declarative style as much as possible and avoid manual state synchronization.
- Very hard to work with uncontrolled forms (i.e. when only initial value is set and no further state synchronization is needed).

```html
<!-- set once and the rest is handled by the wrapping form natively -->
<input name="foo" :value="initialValue">
```

In React you'd have to create an uncontrolled input for every case like that or sync that value via event listener. Since Vue is often used to enhance an existing application this is crucial for some users.

This main focus of this RFC is to find a fair balance between controlled and uncontrolled approach, similar to what Svelte has right now. Shortly, `v-model` behaviour should change to controlled behaviour, `v-bind` should work as is.

There are many benefits that controlled `v-model` has over uncontrolled.

### Improved Developer Experience

Controlled `v-model` gives you full control over value transformations. No more need for manual state synchronization, input prevention and any other hacks that were necessary before.

### Higher-order models

There also opens up an opportunity for higher-order models to provide model-based validation or transformations.

```html
<template>
<TextInput v-model="priceModel">
</template>

<script>
export default {
name: 'PriceInput',
props: ['modelValue'],
computed: {
propModel: {
get() { return this.modelValue },
set(value) {
// User won't be able to pass an invalid number
if (Number.isNaN(Number(value))) return;
this.$emit('update:modelValue', value);
}
}
}
}
</script>
```

### Uncontrolled bindings still possible

It would still be possible to have uncontrolled bindings when necessary. You can achieve that by switching to one-way bindings instead.

```html
<input :modelValue="value" @update:modelValue="onInput">
```

We can also create uncontrolled bindings in place:

```html
<input v-model="([])" type="radio" value="foo">
```

Since `v-model` value is not referencing any reactive value it is uncontrolled.

### Fixes poor `readonly` HTML implementation

HTML's `readonly` attribute prevents input value from any change.
But it doesn't work for radio buttons and checkboxes because their values don't change, but their checked state does.
With a controlled `v-model` we can fix this at ease:

```html
<template>
<input type="radio" v-model="inputModel" :value="value">
</template>

<script>
export default {
name: 'RadioInput',
props: ['modelValue', 'value', 'readonly'],
computed: {
inputModel: {
get() { return this.modelValue },
set(value) {
if (this.readonly) return;
this.$emit('update:modelValue', value);
}
}
}
}
</script>
```

# Detailed design

Below is a comprehensive list of all elements affected:

* `<input>` except for `<input type="file">` which does not support `v-model`
* `<select>`
* `<textarea>`

For any of the elements on the list above the following applies:

## Two-way binding

Element's value should always correspond to `v-model` value.

For computed `v-model` that means the model getter is the source of truth for the element's value.

User authored render functions will be able to opt into the same behaviour by using `vModelDynamic` directive.

## One-way binding, no binding

Elements with one-way value bindings (`v-bind`) or no value binding at all should not be affected by this change.

## Components

Component bindings are not affected by this change since they're already controlled.

# Drawbacks

This is technically a breaking change, but it is questionable whether it does affect anyone. In order to be affected by this change developers have to be reliant on side-effects of current uncontrolled `v-model` behaviour for form elements.

# Alternatives

A `v-model.controlled` modifier has been suggested by Evan You. It can be a reasonable alternative if there's a valid case for `v-model` to lose full control over form element.
It's yet unclear how nested models should work that consist of both controlled and uncontrolled behaviour.

```html
<!-- TextInput.vue -->
<input v-model.controlled="propModel">
```
```html
<!-- is this model controlled? -->
<!-- does it work vice-versa? -->
<TextInput v-model="priceInputModel" />
```

# Adoption strategy

Docs would have to be updated to reflect these changes.
Examples showcasing differences between controlled and uncontrolled elements would be a welcome addition as well.

It's possible to provide `v-model.uncontrolled` modifier for those affected by this change to retain old behaviour.