Skip to content

[Abandoned] API for React Hooks like logic composition #23

@yyx990803

Description

@yyx990803

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:

  1. A set of APIs centered around reactivity, e.g. value, computed and watch. These will be part of the @vue/observer package, and re-exported in the main vue 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.

  2. 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 the created() lifecycle hook of a component.

  3. 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 of created(), 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 of created(), since data() is already used for exposing properties to the template. But it feels a bit weird to perform side effects like watch or useMounted in data().

    • Maybe we can introduce a new option dedicated for this purpose, e.g. state()? (replaces data() and has special handling for refs in returned value)
  • 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
    }
  }
}

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

    Issue actions