20200411 Introducing Teleport aka Portal

Let’s take a look at the new <Teleport> feature, recently renamed from <Portal>, in Vue.js 3, and some of the interesting things you might do with it.

To get a basic of idea what <Teleport> does, read the RFC. Basically, it allows you to render a component at an alternative location on the page by wrapping it in <Teleport>.

A first look with render functions

The first interesting thing I noticed was when writing a render function in TypeScript. For example:

import { createApp, h, defineComponent, Teleport } from 'vue'

const App = defineComponent({
  render() {
    return h(() => [
      h('div', { id: 'dest' }),
      h('div', 'Hello world'),
      h(Teleport, { to: '#dest' })
    ])
  }
})

createApp(App).mount('#app')

The idea here is the final h will be teleported to the first h, specified by the to prop. This will actually give us a compile time error - <Teleport> needs some content to, well, teleport, and we haven’t given it any. This is the first time I’ve actually seen a component give a compile time error when the third argument to h, the children to render, was not provided. We can make this into a working example by adding children:

h(Teleport, { to: '#dest' }, 'This will be teleported')

Another caveat, that may well be a bug, is that <Teleport> and the destination must be in components without a root node - also known as a fragment. That is why I am using the h() => [...]) syntax. I am guessing this is a bug that will be fixed in a future alpha (at the time of this article, the latest version of Vue 3 is alpha-12.

Binding to Teleport

Let’s move away from the world of render functions, back into good old .vue files. We are going to build a small app that let’s us teleport some elements around using Vue’s reactivity. Start with the following:

<template>
  <label for="top">Top</label>
  <input id="top" type="radio" value="top" v-model="selected" />

  <label for="middle">Middle</label>
  <input id="middle" type="radio" value="middle" v-model="selected" />

  <label for="bottom">Bottom</label>
  <input id="bottom" type="radio" value="bottom"  v-model="selected" />
  <br />
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue'

export default defineComponent({
  setup() {
    const selected = ref<'top' | 'middle' | 'bottom'>('top')

    return {
      selected,
    }
  }
})
</script>

So far this doesn’t do much - we have three radio buttons bound to the selected variable. Notice there is no root node - this is the bug I was mentioning earlier.

Next, we will add three destinations - yep, a <Teleport> doesn’t have to be locked to a specific destination:

<template>
  <!-- ... -->
  <h1>Top</h1>
  <div id="top-teleport"></div>
  <h1>Middle</h1>
  <div id="middle-teleport"></div>
  <h1>Bottom</h1>
  <div id="bottom-teleport"></div>
</template>

You might see what’s coming next - now we have 3 radio buttons and 3 destinations, let’s finally add in the <Teleport>:

<template>
  <!-- ... -->
  <teleport :to="destination">
    This will teleport
  </teleport>
</template>

We are binding to a destination variable. Let’s create that and make sure it updates when the selected does using a computed property:

<script lang="ts">
import { defineComponent, ref, computed } from 'vue'

export default defineComponent({
  setup() {
    const selected = ref<'top' | 'middle' | 'bottom'>('top')

    const destination = computed(() => `#${selected.value}-teleport`)

    return {
      destination,
      selected,
    }
  }
})
</script>

This is enough to get everything working! It’s hard to appreciate what’s going on - best watch the accompanying screencast to really get an idea, but as you change the selected <Teleport> using the radio buttons, the content is reactively teleported!

The main use case for this is probably a modal, or something that needs to be rendered at the top level of the application, but is triggered from somewhere else.

Disabling a <Teleport>

It is also possible to disable a <Teleport>. A good use case for this would be when you want to close a modal, potentially. Let’s add a checkbox to support this:

<template>
  <!-- ... -->
  <label for="top-disabled">Disabled</label>
  <input id="top-disabled" type="checkbox" v-model="disabled" />

  <teleport :to="destination" :disabled="disabled">
  </teleport>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue'

export default defineComponent({
  setup() {
    //...
    const disabled = ref(false)

    return {
      disabled,
      destination,
      selected,
    }
  }
})
</script>

Now if you try disabling the <Teleport>, you will notice the original content is rendered back where it started - this this case, at the bottom of the page.

<Teleport> maintains the state of the DOM

When content is moved around, it preserves the state of the DOM. A good example of this is using a <video> element. If you move a <video> that is playing using a <Teleport>, the video will keep on playing! <Teleport> isn’t rerendering the DOM element - it’s really moving the actual element as is, without breaking the state or rerendering it. Very cool.

Conclusion

We learned some of the neat features of <Teleport>, how to bind to it’s to prop, about it’s disabled prop, and how it preseves the state of the DOM elements it is moving.