-
-
Notifications
You must be signed in to change notification settings - Fork 8.8k
Description
Note: this has been split into two separate RFCs: #24 & #25
- Start Date: 03-05-2019
- Target Major Version: 2.x & 3.x
- Reference Issues: N/A
- Implementation PR: N/A
Summary
A set of React-hooks-inspired APIs for composing and reusing stateful logic across components.
Basic example
In Object API
import { value, computed, watch, useMounted, useDestroyed } from 'vue'
export default {
created() {
// value returns a value "ref" that has a .value property
const count = value(0)
// computed returns a computed "ref" with a read-only .value property
const plusOne = computed(() => count.value + 1)
// refs can be watched directly (or explicitly with `() => count.value`)
watch(count, (count, oldCount) => {
console.log(`count changed to: ${count}`)
})
watch(plusOne, countPlusOne => {
console.log(`count plus one changed to: ${countPlusOne}`)
})
useMounted(() => {
console.log('mounted')
})
useDestroyed(() => {
console.log('destroyed')
})
// bind refs as properties to the instance. This exposes
// this.count as a writable data property and this.plusOne as a read-only
// computed property
return {
count,
plusOne
}
}
}
In Class API
Usage in a Class-based API would be exactly the same:
import Vue, { useMounted } from 'vue'
export default class MyComponent extends Vue {
created() {
useMounted(() => {
console.log('mounted')
})
}
}
Motivation
- React hooks like composition, but fits Vue's idiomatic usage
- A more composable replacement for Mixins
Detailed design
Summary
This proposal consists of three parts:
-
A set of APIs centered around reactivity, e.g.
value
,computed
andwatch
. These will be part of the@vue/observer
package, and re-exported in the mainvue
package. These APIs can be used anywhere and isn't particularly bound to the usage outlined in this proposal, however they are quintessential in making this proposal work. -
A set of call-site constrained APIs that registers additional lifecycle hooks for the "current component", e.g.
useMounted
. These functions can only be called inside thecreated()
lifecycle hook of a component. -
The ability for the
created()
lifecycle hook to return an object of additional properties to expose on the component instance.
Reactivity APIs
In Vue 2.x, we already have the observable
API for creating standalone reactive objects. Assuming we can return an object of additional properties to expose on this
from created()
, we can achieve the following:
import { observable } from 'vue'
const App = {
created() {
const state = observable({
count: 0
})
const increment = () => {
state.count++
}
// exposed on `this`
return {
state,
increment
}
},
template: `
<button @click="increment">{{ state.count }}</button>
`
}
The above is a contrived example just to demonstrate how it could work. In practice, this is intended mainly for encapsulating and reusing logic much more complex than a simple counter.
Value Container
Note in the above example we had to expose an object (so that Vue can register the dependency via the property access during render) even though what we are really exposing is just a number. We can use the value
API to create a container object for a single value, called a "ref". A ref is simply a reactive object with a writable value
property that holds the actual value:
import { value } from 'vue'
const countRef = value(0)
// read the value
console.log(countRef.value) // 0
// mutate the value
countRef.value++
The reason for using a container object is so that our code can have a persistent reference to a value that may be mutated over time.
A value ref is very similar to a plain reactive object with only the .value
property. It is primarily used for holding primitive values, but the value can also be a deeply nested object, array or anything that Vue can observe. Deep access are tracked just like typical reactive objects. The main difference is that when a ref is returned as part of the return object in created()
, it is bound as a direct property on the component instance:
import { value } from 'vue'
const App = {
created() {
return {
count: value(0)
}
},
template: `
<button @click="count++">{{ count }}</button>
`,
}
A ref binding exposes the value directly, so the template can reference it directly as count
. It is also writable - note that the click handler can directly do count++
. (In comparison, non-ref bindings returned from created()
are readonly).
Computed State
In addition to writable value refs, we can also create standalone computed refs:
import { value, computed } from 'vue'
const count = value(0)
const countPlusOne = computed(() => count.value + 1)
console.log(countPlusOne.value) // 1
count.value++
console.log(countPlusOne.value) // 2
Computed refs are readonly. Assigning to its value
property will result in an error.
Watchers
All .value
access are reactive, and can be tracked with the standalone watch
API.
import { value, computed, watch } from 'vue'
const count = value(0)
const double = computed(() => count.value * 2)
// watch and re-run the effect
watch(() => {
console.log('count is: ', count.value)
})
// -> count is: 0
// 1st argument (the getter) can return a value, and the 2nd
// argument (the callback) only fires when returned value changes
watch(() => count.value + 1, value => {
console.log('count + 1 is: ', value)
})
// -> count + 1 is: 1
// can also watch a ref directly
watch(double, value => {
console.log('double the count is: ', value)
})
// -> double the count is: 0
count.value++
// -> count is: 1
// -> count + 1 is: 2
// -> double the count is: 2
Note that unlike this.$watch
in 2.x, watch
are immediate by default (defaults to { immediate: true }
) unless a 3rd options argument with { lazy: true }
is passed:
watch(
() => count.value + 1,
() => {
console.log(`count changed`)
},
{ lazy: true }
)
The callback can also return a cleanup function which gets called every time when the watcher is about to re-run, or when the watcher is stopped:
watch(someRef, value => {
const token = performAsyncOperation(value)
return () => {
token.cancel()
}
})
A watch returns a stop handle:
const stop = watch(...)
// stop watching
stop()
Watchers created inside a component's created()
hook are automatically stopped when the owner component is destroyed.
Lifecycle Hooks
For each existing lifecycle hook (except beforeCreate
and created
), there will be an equivalent useXXX
API. These APIs can only be called inside the created()
hook of a component. The prefix use
is an indication of the call-site constraint.
import { useMounted, useUpdated, useDestroyed } from 'vue'
export default {
created() {
useMounted(() => {
console.log('mounted')
})
useUpdated(() => {
console.log('updated')
})
useDestroyed(() => {
console.log('destroyed')
})
}
}
Unlike React Hooks, created
is called only once, so these calls are not subject to call order and can be conditional.
useXXX
methods automatically detects the current component whose setup()
is being called. The instance is also passed into the registered lifecycle hook as the argument. This means they can easily be extracted and reused across multiple components:
import { useMounted } from 'vue'
const useSharedLogic = () => {
useMounted(vm => {
console.log(`hello from component ${vm.$options.name}`)
})
}
const CompA = {
name: 'CompA',
created() {
useSharedLogic()
}
}
const CompB = {
name: 'CompB',
created() {
useSharedLogic()
}
}
A More Practical Example
Let's take the example from React Hooks Documentation. Here's the equivalent in Vue's idiomatic API:
export default {
props: ['id'],
data() {
return {
isOnline: null
}
},
created() {
ChatAPI.subscribeToFriendStatus(this.id, this.handleStatusChange)
},
destroyed() {
ChatAPI.unsubscribeFromFriendStatus(this.id, this.handleStatusChange)
},
watch: {
id: (newId, oldId) => {
ChatAPI.unsubscribeFromFriendStatus(oldId, this.handleStatusChange)
ChatAPI.subscribeToFriendStatus(newId, this.handleStatusChange)
}
},
methods: {
handleStatusChange(status) {
this.isOnline = status
}
}
}
And here's the equivalent using the APIs introduced in this proposal:
import { value, computed, watch } from 'vue'
export default {
props: ['id'],
created() {
const isOnline = value(null)
function handleStatusChange(status) {
isOnline.value = status
}
watch(() => this.id, id => {
// this is called immediately and then very time id changes
ChatAPI.subscribeToFriendStatus(id, handleStatusChange)
return () => {
// this is called every time id changes and when the component
// is unmounted (which causes the watcher to be stopped)
ChatAPI.unsubscribeFromFriendStatus(id, handleStatusChange)
}
})
return {
isOnline
}
}
}
Note that because the watch
function is immediate by default and also auto-stopped on component unmount, it achieves the effect of watch
, created
and destroyed
options in one call (similar to useEffect
in React Hooks).
The logic can also be extracted into a reusable function (like a custom hook):
import { value, computed, watch } from 'vue'
function useFriendStatus(idRef) {
const isOnline = value(null)
function handleStatusChange(status) {
isOnline.value = status
}
watch(idRef, id => {
ChatAPI.subscribeToFriendStatus(id, handleStatusChange)
return () => {
ChatAPI.unsubscribeFromFriendStatus(id, handleStatusChange)
}
})
return isOnline
}
export default {
props: ['id'],
created() {
return {
// to pass watch-able state, make sure to pass a value
// or computed ref
isOnline: useFriendStatus(computed(() => this.id))
}
}
}
Note that even after logic extraction, the component is still responsible for:
- Declaring all the props it expects
- Declaring all the properties to expose to the template
Even with multiple extracted custom logic functions, there will be no confusion regarding where a prop or a data property comes from. This is a major advantage over mixins.
Drawbacks
-
Type inference of returned values. This actually works better in the object-based API because we can reverse infer
this
based on the return value ofcreated()
, but it's harder to do so in a class. The user will likely have to explicitly annotate these properties on the class. -
To pass state around while keeping them "trackable" and "reactive", values must be passed around in the form of ref containers. This is a new concept and can be a bit more difficult to learn than the base API. However, this isn't intended as a replacement for the base API - it is positioned as a advanced mechanism to encapsulate and reuse logic across components.
Alternatives
Compared to React hooks:
- same composition capability
- closer mapping to Vue's existing usage
- no repeated invocation, less wasted memory on repeated render
watch
has automatic dependency tracking, no need to worry about exhaustive deps- reactive state are always referenced via refs so no confusion of stale closures
- does not rely on call order
Adoption strategy
TODO
Unresolved questions
-
We can also use
data()
instead ofcreated()
, sincedata()
is already used for exposing properties to the template. But it feels a bit weird to perform side effects likewatch
oruseMounted
indata()
.- Maybe we can introduce a new option dedicated for this purpose, e.g.
state()
? (replacesdata()
and has special handling for refs in returned value)
- Maybe we can introduce a new option dedicated for this purpose, e.g.
-
We probably need to also expose a
isRef
method to check whether an object is a value/computed ref.
Appendix: More Usage Examples
Data Fetching
This is the equivalent of what we can currently achieve via scoped slots with libs like vue-promised. This is just an example showing that this new set of API is capable of achieving similar results.
function useFetch(endpointRef) {
const res = value({
status: 'pending',
data: null,
error: null
})
// watch can directly take a computed ref
watch(endpointRef, endpoint => {
let aborted = false
fetch(endpoint)
.then(res => res.json())
.then(data => {
if (aborted) {
return
}
res.value = {
status: 'success',
data,
error: null
}
}).catch(error => {
if (aborted) {
return
}
res.value = {
status: 'error',
data: null,
error
}
})
return () => {
aborted = true
}
})
return res
}
// usage
const App = {
created(props) {
return {
postData: useFetch(computed(() => `/api/posts/${props.id}`))
}
},
template: `
<div>
<div v-if="postData.status === 'pending'">
Loading...
</div>
<div v-else-if="postData.status === 'success'">
{{ postData.data }}
</div>
<div v-else>
{{ postData.error }}
</div>
</div>
`
}
Use the Platform
Hypothetical examples of exposing state to the component from external sources. This is more explicit than using mixins regarding where the state comes from.
export default {
created() {
const { x, y } = useMousePosition()
const orientation = useDeviceOrientation()
return {
x,
y,
orientation
}
}
}