tailwindlabs/tailwindui-vue

[Proposal] Implement logic in composition functions

AlexVipond opened this issue · 12 comments

When Vue 3 comes out, it will be possible to extract logic into composition functions. I'm refactoring Adam's code into a proof-of-concept useListbox function, to see if I can get the below API to work.

Would love some community feedback on this API vs the renderless components approach!

Here's my repo: https://github.com/AlexVipond/vue

<template>
  <div
    ref="listbox.root.ref"
    v-bind="{ ...listbox.root.bindings }"
    v-on="{ ...listbox.root.listeners }"
  >
    <span
      ref="listbox.label.ref"
      v-bind="{ ...listbox.label.bindings }"
    >
      Select a wrestler:
    </span>
    <button
      class="rounded p-3 border"
      v-bind="{ ...listbox.button.bindings }"
      v-on="{ ...listbox.button.listeners }"
    >
      {{ selectedWrestler }}
    </button>
    <ul
      ref="listbox.list.ref"
      v-show="listbox.list.isOpen"
      v-bind="{ ...listbox.list.bindings }"
      v-on="{ ...listbox.list.listeners }"
    >
      <li
        v-for="({ value, bindings, listeners, isActive, isSelected }) in listbox.options"
        :key="value"
        v-bind="{ ...bindings }"
        v-on="{ ...listeners }"
        ref="listbox.options.ref"
      >
        <div 
          class="p-3" 
          :class="isActive ? 'bg-blue-600 text-white' : 'bg-white text-gray-900'"
        >
          {{ value }}
          <img v-show="isSelected" src="/checkmark.svg">
        </div>
      </li>
    </ul>
  </div>
</template>

<script>
  import { useListbox } from '@tailwindui/vue'

  export default {
    setup () {
      const listbox = useListbox({
              options: [
                'The Ultimate Warrior',
                'Randy Savage',
                'Hulk Hogan',
                'Bret Hart',
                'The Undertaker',
                'Mr. Perfect',
                'Ted DiBiase',
                'Bam Bam Bigelow',
                'Yokozuna',
              ],
              defaultOption: 'The Ultimate Warrior'
            })
    
      return {
        listbox,
      }
    }      
  }
</script>

Benefits of composition function approach:

  • Cedes full control of HTML semantics to the user
  • Eliminates significant internal complexity
    • Doesn't use provide/inject, everything is in scope internally
    • Doesn't need to register slots as option refs
  • The composition function includes no render logic or render functions whatsoever. This leaves the user free to define their own SFC template, which Vue can optimize more easily than a plain render function.
  • The user does not need to know how to use slots or scoped slots
  • Internal code can be collocated based on its purpose, rather than split across several different components
  • Only need to import one function—no need to import and register lots of different components.

I thought about doing something similar when I converted this to React (https://github.com/schrapel/tailwind-ui-nextjs-listbox-example/blob/master/components/Listbox.jsx)

I did not like the idea of adding all the binding and listeners from the hook. I will give this another go tomorrow in React and see what I prefer. I do like the idea of having full control of the HTML semantics but I do think I prefer hiding the complexity internally

Yeah, adding all the bindings and listeners gets verbose, especially in Vue where you have to bind ref, listeners, and all other attributes separately. In React it's a little cleaner, since you can spread out the listeners and bindings from the same object.

Once I get my proof of concept done, I'm going to work on an API that would let the user just add the appropriate ref to each element. From there, the composition function should be able to handle all attribute binding and event listening internally, with a little extra work.

Have more thoughts on this I'll reply with when I have more time (probably Monday) but useful to know that in Vue 3 you can bind everything at once:

<div v-bind="{ ...everything }">

Just need to make sure your listeners start with on in the object you spread in. v-bind accepts the entire new VNode object.

@AlexVipond I was thinking about this more this morning and then remembered about this package I used on a previous project https://github.com/downshift-js/downshift.

https://codesandbox.io/s/53qfj?file=/index.js

I am starting to think this actually makes way more sense.

Finally found time to circle back on this! It sparked a lot of thoughts that I'm writing some blog posts on, but here's where I'm at:

I was able to get a Vue 3 composition function to do the following:

  • Implement core logic of all the compound components
  • Handle all attribute binding
  • Handle event listening (including removing listeners when the component unmounts)
  • Return refs that the end user can attach to their the HTML for a clean experience that looks almost identical to the original compound component API

@adamwathan @RobinMalfait would love to get your eyes on this!

Here's a live demo: https://vue-3-tailwindui-listbox.netlify.app/
Demo repo (built with Vite): https://github.com/AlexVipond/vue-3-tailwindui-listbox-demo
Composition function source code: https://github.com/AlexVipond/tailwindui-vue/blob/master/src/useListbox.js

  • Tightly coupled logic is collocated!
  • Was able to completely avoid provide and inject, since everything is in scope of the function
  • Less boilerplate makes this about 75 lines shorter than the original Listbox.js file. That's with comments included, and reasonable line lengths (I tried to avoid crazy one-line functions and objects).

Again, the goal here is to let the end user attach refs to their markup, access a few key pieces of reactive data like isOpen, and completely avoid all the other implementation details.

The API/dev experience I used in the demo looks like this:

<template>
  <div>
    <span
      :ref="listbox.label.ref"
    >
      Select a wrestler:
    </span>
    <button
      :ref="listbox.button.ref"
      class="rounded p-3 border"
    >
      {{ listbox.selected }}
    </button>
    <ul
      :ref="listbox.list.ref"
      v-show="listbox.list.isOpen"
    >
      <li
        v-for="({ value, isActive, isSelected }) in listbox.options.values"
        :key="value"
        :ref="listbox.options.ref"
      >
        <div 
          class="p-3" 
          :class="isActive ? 'bg-blue-600 text-white' : 'bg-white text-gray-900'"
        >
          {{ value }}
          {{ isSelected }}
        </div>
      </li>
    </ul>
  </div>
</template>

<script>
import { useListbox } from '@tailwindui/vue'

export default {
  setup () {
    const listbox = useListbox({
            options: [
              'The Ultimate Warrior',
              'Randy Savage',
              'Hulk Hogan',
              'Bret Hart',
              'The Undertaker',
              'Mr. Perfect',
              'Ted DiBiase',
              'Bam Bam Bigelow',
              'Yokozuna',
            ],
            defaultOption: 'The Ultimate Warrior'
          })
  
    return {
      listbox,
    }
  }      
}
</script>

With that API achieved, it's still super possible to offer compound components with very little extra work. I don't have a working demo yet, but it should be possible to call useListbox in the top level Listbox component, then use provide and inject to pass down the various refs wherever they need to go. Essentially, you'd be wiring up the component boilerplate, while still managing all the core logic together in the composition function.

Also haven't tested Vue 2 compatibility yet, but the @vue/composition-api plugin supports every feature and syntax I used. Vue 2 apps with the plugin installed should have no problems.

Even moving this to React or a different framework should be a lot less painful compared to compound components. The composition function approach really cleanly separates the UI logic from Vue APIs. I'm excited to dig more deeply on that!

A note on attribute binding and event listening

The trick for attribute binding and event listening was to re-implement the Vue affordances that you get from templates and render functions.

For now, I implemented that logic as additional composition functions in a package I'm working on:

  • useBindings for binding attributes, classes, and styles (both static and reactive bindings)
  • useListeners for adding event listeners, and removing before the component unmounts

I personally would prefer if Vue exposed their implementation of that logic, which can be found here. I'm considering making a feature request, since it would allow composition function authors to avoid third party rewrites of code that is getting shipped in every Vue app regardless.

vms82 commented

@AlexVipond the demo is very high cpu intensive (hitting 100%) when i interact withe the keyboard or the mouse.

@vms82 What browser are you on? So far, I can't replicate—I'm mashing the keyboard on Chrome and Firefox and still can't push CPU usage very high.

vms82 commented

@AlexVipond tested it with chrome 84 and 85 just hold down up or down or hover fast with the mouse with open listboxoptions and see the performance monitor in the console.I feel like this has something to do with the amount of bindings and props that need to update based on my event flood actions and its locking the ui since its waiting for every event in the "queue" to finish its job updating things.

@vms82 Thanks I was able to replicate based on that.

To try and solve it, I debounced or removed every single event-related DOM update (on my local version, not the published one).

I also switched to a debouncing library I wrote a while back that uses requestAnimationFrame under the hood instead of setTimeout, which is what powers the debounce dependency used by Tailwind UI. requestAnimationFrame is more efficient in most cases, as I understand it.

Both of those things should lighten the load considerably in theory, but didn't seem to help that much in practice.

So then I put together this JSFiddle that is literally just one DOM element with a mousemove listener that logs to the console. If I move my mouse quickly over that element, I can easily get Chrome 84 to push my CPU to 70-80% usage, leading me to believe that rapid mousemove and keydown events are simply not performant. Curious to know if you get the same results!

I think it's also worth noting that the typical end user of this listbox implementation wouldn't be rapidly moving their mouse a lot, and they wouldn't be holding down an arrow key to rapidly cycle through options, so it probably wouldn't be an issue in real world use cases.

vms82 commented

@AlexVipond changing the event to mouseenter instead of mousemove would reduce the even spam since its fired only once.

@vms82 Yep, that definitely makes sense. Let's discuss in a separate issue, though, I'd love to keep this issue more abstract and focused on the discussion about using composition functions to implement the core logic of this entire project.

Hey! I'm deprecating this repository in favor of the Headless UI repo. If this is still an issue please re-open the issue in the new repository. Thank you!