How to build Portals in Vue.js

whoami

  • Vue core team member since ~06/2016
  • "That guy on the forum" answering your questions 
  • Programming is not my job 😱
  • Twitter: @linus_borg
  • Github: linusborg 

The what and why of Portals

A naive Implentation

A better implementation

Available Solutions

Caveats

A Portal moves its children from their original location to another location in the DOM

Gif saying "But why?"
<Layout />
    <Profile />
        <Settings />
            <EditButton />

            <EditModal v-if="editButtonClicked" />

How do we do that?

  • We want to render this modal here
  • position it absolutely, relative to the viewport
  • but some ancestor element is already relatively position

We move that Modal somewhere else in the DOM

 

    ...with a Portal

Answer

A naive Implementation

<portal to="#some-element">
  <Modal>
    <form>
      <input v-model="username"/>
      <button @click="save">
        Save
      </button>
    </form>
  </Modal>
</portal>
const Portal = Vue.extend({
  props: ['to'],
  template: `
    <div ref="root">
      <slot />
    </div>
  `,
  mounted() {
    const kids = [...this.$refs.root.childNodes]
    const target = document.querySelector(this.to)
    kids.forEach(node => {
      target.append(node)
    })
  }
})

The Good

  • It's simple
  • It works (mostly*)
  • Changes to the slot content even update!

The Bad

  • It works - until it doesn't
  • Real DOM and Virtual DOM are out of sync
  • Vue's DOM updates may break
  • It doesn't feel clean, right?

This solution is inadequate

  • Some situations work, others don't
  • Vue should be the only one moving stuff around
  • We don't want to touch the DOM anway, right?

A better Implementation

Letting Vue do it's thing

The basic idea

Instead of moving the DOM elements...

...we mount a small component...

...and pass it the content of our portal's slot.

<portal to="#some-element">
  <Modal>
    <form>
      <input v-model="username"/>
      <button @click="save">
        Save
      </button>
    </form>
  </Modal>
</portal>
const Portal = Vue.extend({
  props: ['to'],
  render(h) { return h() },
  mounted() {
    const mountEl = document.createElement('div')
    document.querySelector(this.to).append(mountEl)
    const content = this.$slots.default
    this.target = new Wrapper({
      el: mountEl,
      propsData: {
       content,
      },
    })
  }
})

Demo Time!

I've built not one, but two portal libraries

Thank you!

Made with Slides.com