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

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

Closed
yyx990803 opened this issue Mar 12, 2019 · 15 comments
Closed

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

yyx990803 opened this issue Mar 12, 2019 · 15 comments

Comments

@yyx990803
Copy link
Member

yyx990803 commented Mar 12, 2019

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
    }
  }
}
@LinusBorg
Copy link
Member

LinusBorg commented Mar 12, 2019

Hey Evan!

I like this. I don't have any real opinion on the details like created() vs. data() vs. state(), but the general syntax/workflow seems great.

I would like to see the risk of overwhelming users with too many new things, or rather - too many choices with 3.0 - being adressed, though.

I really think there will be FUD over object vs. class syntax already. We will of course adress that by recommending for people to stick to what they have similar to how React doesn't recommend converting all class components to functional components + hooks.

But just like in the React community, despite that recommendation, some level of FUD will exist.

Introducing this totally new way of doing things in 3.0 will probably have the same effect:

  • "When I migrate to Vue 3.0, should I migrate my mixins to hooks or not? are mixins dead? They seem inferior, so why stick with them?"
  • some random Blog post "7 reasons to switch to Vue hooks NOW" (it's always 7 reasons, btw - why?)

etc, pp.

We can only do so much to contain that, and I would hate for people getting the impression that 3.0 does an Angular move by changing "everything" (however wrong that impression may be).

Just as an idea worth discussing - Would we maybe consider introducting this as explicitly experimental, or publish it in 3.1, after 3.0 has stabilized after release?

@LinusBorg
Copy link
Member

Another thought crossing my mind: Since this is a completely new API, shouldn't we be able to port it to v2, or at least 2.x-next (which will be using the new Porxy-based Reactivity system?

Would make it possible to use new packages that expose hooks in in 2.0 apps that are not ready tpo be migrated.

And yes, I'm aware that this kind of contradicts my previous point about releasing hooks later :-P

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 12, 2019

@LinusBorg I totally agree. This is in fact my biggest concern as well.

But I think that really depends on how we present it. Here this internal proposal is presenting it directly as a hooks equivalent because that's what it is aiming to be - however, we don't really have to introduce it to the users that way.

Notice that the reactivity APIs (value, computed and watch) are not really bound to hooks - they can be used anywhere and are useful in their own right, exposing more capabilities of Vue's reactivity system. The standalone watch function can be a direct replacement of the current watch option and this.$watch method (and it is tree-shakable!).

If we use data() instead of created(), then all we are adding is the ability to bind refs in the returned object, which also makes sense.

The only part that is really hooks-like is the useXXX APIs - this is in fact similar to 2.x's vm.$on('hook:mounted'). So we actually already have the equivalent (but less ergonomic) capabilities in 2.x today:

function useSomeLogic(vm) {
  const count = Vue.observable({ value: 0 })
  vm.$watch(() => count.value, value => {
    // ...
  }, { immediate: true })
  vm.$on('hook:mounted', () => {
    // ...
  })
  return count
}

export default {
  template: `<div>{{ count.value }}</div>`,
  data() {
    return {
      count: useSomeLogic(this)
    }
  }
}

This proposal isn't really introducing anything drastically different, but rather polishing some existing APIs to make them work better when used to encapsulate and reuse logic.

@Akryum
Copy link
Member

Akryum commented Mar 12, 2019

Some thoughts:

  • What about computed setters?
  • Why not onMounted instead of useMounted? I find it more natural to write. Maybe having onXXX for side-effects only "'hooks" is more Vue-like, and useXXX could be for "hooks" that returns a reference?
  • I'm not so sure about having watch being immediate by default, it clashes with the existing watch API we use everywhere in Vue 2.x, adding to the FUD. I get it's useful though, so maybe we could expose another hook like runWatch, doWatch or watchImmediate?
  • What if we want to watch a ref returned in created, or use it in a computed property? Since those are defined before created is called, won't there be issues? What about having a new lifecycle hook, like bind for example that gets called after data and computed but before watchers are setup? How about "hooks" that rely on computed properties that in turn could rely on references?

@LinusBorg
Copy link
Member

LinusBorg commented Mar 12, 2019

What about computed setters?

Would that work?

function computedWithSetter(getter, setter) {

  const computedRef = computed(getter)
  return Object.defineProperty(res, 'value',{
    get() { return computedRef.value }
    set: setter,
  })
}
created() {
  const computedWSetterRef = computedWithSetter(() => someRef.value, () => /*do whatever*/ )

  return {
    computedWSetterRef
  }
}
<input v-model="computedWSetterRef.value">

I'm not so sure about having watch being immediate by default,

I think it's fine. There's a lot of use cases where I feel watchers would be more useful and code shorter if they were immediate by default.

What if we want to watch a ref returned in created, or use it in a computed property? Since those are defined before created is called, won't there be issues?

Tricky ... :/ Tried to simulate it in 2.0 and failed to make it work.

I like adding it to created as I think it fits the mental model - we create a component, then add additional behaviours. but that indeed makes it hard / impossible to reference them in computed props or watchers afaict.

beforeCreate doesn't seem to fit as well, as now we can't access any data, $store and whatnot?

@yyx990803
Copy link
Member Author

@LinusBorg if a ref is returned there's no need to use .value in the template so it could just be v-model="computedWSetterRef"

What if we want to watch a ref returned in created, or use it in a computed property? Since those are defined before created is called, won't there be issues?

Yeah I think in that case a new hook that is called after props initialization but before computed initialization would be useful. As for watchers - I'm thinking of removing watch option and this.$watch altogether because they are non-treeshakable.

@LinusBorg
Copy link
Member

LinusBorg commented Mar 13, 2019

I'm thinking of removing watch option and this.$watch altogether because they are non-treeshakable.

Considering our promise of keeping the Vue 3 API largely compatible to 2.0, I don't think we should/can remove APIs for the sole reason of shaving a few bytes of the final bundle in a few use cases.

@yyx990803
Copy link
Member Author

@LinusBorg it is trivial to support in the migration build, and in most cases can be automatically converted. The only reason this.$watch is exposed on this is so that the watcher can be stopped when the component is destroyed. A generic watch API can and should be usable in any context. However if we introduce that we end up with 3 different ways of watching things:

  1. this.$watch
  2. the watch option
  3. the globally imported watch method.

Where (3) provides a superset of (1) and (2). In this case, (1) and (2) just seem pointless and redundant.

@posva
Copy link
Member

posva commented Mar 13, 2019

is it possible to hit a middleground with a compat plugin that adds those features (watch for example) with warnings an cli hints to do the automatic conversion?

@LinusBorg
Copy link
Member

Unlike React Hooks, created is called only once, so these calls are not subject to call order and can be conditional.

Good point. Another positive effect of this, combined with the reactivity system v. React's immutable state, it that we don't have to worry about closures breaking stuff if the wrong references are included in it, or dependency tracking in React's useEffect.

We should really stress this point as from what I read about problems with hooks, people really fall a lot for these kind of unwritten / hidden rules - which is why React also did an eslint plugin to keep people from shooting themselves in the foot.

We don't really need that, do we?

OTOH, The point about referencing hook refs from within computed etc. might be our version of such footguns.

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 14, 2019

@LinusBorg

Yes, this proposal should work more along the lines of typical JavaScript intuitions than React hooks.

Re refs: once a ref is exposed to the instance they just become normal properties.

For the sake of discussion, let's call it "Vue blocks" (to differentiate from React hooks and lifecycle hooks)... we extract and encapsulate logic inside a block, and then expose it to the instance. Let's call it "the block side" and "the instance side":

import { value } from 'vue'

funciton useCustomBlock() {
  // the block side
  return value(0)
}

export default {
  created() {
    return {
      // expose a ref to the instance (becomes a normal root-level property)
      count: useCustomBlock()
    }
  },
  computed: {
    countPlusOne() {
      // the instance side
      // no need to use .value
      return this.count + 1
    }
  }
}

You will only create and use refs on the blocks side, because there is not a persistent this context to reference values that will change over time. But once refs are bound to an instance, there's no longer the need for them to be refs, so they can just work like normal properties.

@chrisvfritz
Copy link
Contributor

chrisvfritz commented Mar 18, 2019

@yyx990803 I might have some ideas for how we could build on top of current mixins instead and avoid adding much new API or concepts, but first I want to make sure I'm understanding the full advantages this syntax provides. The ones I've seen are:

  • We're explicit about what's exposed to the component rather than forcing users to jump through hoops to make some properties private.
  • Components explicitly define what's added to the instance from a mixin, rather than this information being invisible.
  • It's possible to mix in behavior after the component is created, e.g. using data from props like in the useFriendStatus example.

Is that everything, or are there other advantages/goals?

@LinusBorg
Copy link
Member

I think another aspect, not necessarily related to mixins, is that you don't spread parts of a behaviour over different parts of your component, i.e. data&created&watch&destroyed. You can define the whole lifecycle of a piece of behaviour in one place.

@yyx990803
Copy link
Member Author

yyx990803 commented Mar 19, 2019

Another important aspect is the ability to compose multiple "logic modules" together by passing state between them (while not polluting the namespace of the host component):

export default {
  props: ['id'],
  created() { 
    const position = useMousePosition()
    const orientation = useDeviceOrientation()
    const matrix = useMatrix(x, y, orientation)

    return {
      matrix
    }
  }
}

So instead of multiple mixins all competing on the same this namespace for state persistence (and risk clashing with one another), we can just pass refs around via function arguments.

@yyx990803 yyx990803 changed the title API for React Hooks like logic composition [Abandoned] API for React Hooks like logic composition Mar 20, 2019
@yyx990803
Copy link
Member Author

This has been split into two separate RFCs: #24 & #25

@github-actions github-actions bot locked and limited conversation to collaborators Nov 17, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants