Vue.js Workshop

Guillaume Chau

@Akryum

Vue.js Core Team

Prologue:
The Progressive Framework

💡️ = community-made

Faster

More memory efficient

Smaller and more tree-shakable

Better TypeScript support

Vue 3

Migrating from Vue 2 to Vue 3

App Creation

import Vue from 'vue'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
import App from './App.vue'

Vue.use(VueRouter)
Vue.use(Vuex)

const router = new VueRouter(...)
const store = new Vuex.Store(...)

new Vue({
  el: '#app',
  router,
  store,
  // render: h => h(App),
  ...App,
})
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import { createStore } from 'vuex'
import App from './App.vue'

const router = createRouter(...)
const store = createStore(...)

const app = createApp(App)
app.use(router)
app.use(store)
app.mount('#app')

Removed: Filters

<template>
  <div>
    {{ date | formatDate }}
  </div>
</template>
<template>
  <div>
    {{ formatDate(date) }}
  </div>
</template>
<template>
  <div>
    {{ date |> formatDate(%) }}
  </div>
</template>

In the future?

(stage 2)

v-model changes

<template>
  <MyComponent v-model="msg" />  
</template>
export default {
  model: {
    prop: 'count',
    event: 'update',
  },

  props: {
    count: {
      type: Number,
      required: true,
    },
  },

  emits: [
    'update',
  ],
}
<template>
  <MyComponent v-model="msg" />  
</template>
export default {
  props: {
    modelValue: {
      type: Number,
      required: true,
    },
  },

  emits: [
    'update:modelValue',
  ],
}

v-model changes

<template>
  <MyComponent v-model="msg" />  
</template>
export default {
  model: {
    prop: 'count',
    event: 'update',
  },

  props: {
    count: {
      type: Number,
      required: true,
    },
  },

  emits: [
    'update',
  ],
}
<template>
  <MyComponent v-model:count="msg" />  
</template>
export default {
  props: {
    count: {
      type: Number,
      required: true,
    },
  },

  emits: [
    'update:count',
  ],
}

v-model changes

<template>
  <MyComponent :count.sync="msg" />  
</template>
export default {
  props: {
    count: {
      type: Number,
      required: true,
    },
  },

  emits: [
    'update:count',
  ],
}
<template>
  <MyComponent v-model:count="msg" />  
</template>
export default {
  props: {
    count: {
      type: Number,
      required: true,
    },
  },

  emits: [
    'update:count',
  ],
}

Attribute changes

Nested component inheritance

<script>
export default {
  inheritAttrs: false,
}
</script>

<template>
  <SomeComponent
    v-bind="$attrs"
  >
    <slot />
  </SomeComponent>
</template>
<template>
  <SomeComponent>
    <slot />
  </SomeComponent>
</template>

Attribute changes

Class and style included in $attrs

<script>
export default {
  inheritAttrs: false,
}
</script>

<template>
  <section>
    <input
      v-bind="$attrs"
    >
  </section>
</template>
<script>
export default {
  inheritAttrs: false,
}
</script>

<template>
  <section
    v-bind="{
      class: $attrs.class,
      style: $attrs.style,
    }"
  >
    <input
      v-bind="{
        ...$attrs,
        class: null,
        style: null,
      }"
    >
  </section>
</template>
<template>
  <MyComponent
    type="password"
    class="foobar"
  />
</template>

Attribute changes

Class and style included in $attrs

Why? (Sneak peek at new features)

<script>
export default {
  inheritAttrs: false,
}
</script>

<template>
  <Teleport to="body">
    <div v-bind="$attrs">
      <slot />
    </div>
  </Teleport>
</template>
<template>
  <div v-bind="$attrs">Div 1</div>
  <div v-bind="$attrs">Div 2</div>
  <div>Div 3</div>
</template>

Attribute changes

Listeners fallthrough

<!-- MyComponent -->

<template>
  <SomeChild />
</template>
<template>
  <MyComponent
    @click.native="onClick"
  />
</template>
<template>
  <MyComponent
    @click="onClick"
  />
</template>

Attribute changes

Listeners fallthrough

<script>
export default {
  inheritAttrs: false,
}
</script>

<template>
  <div>
    <input
      v-bind="$attrs"
      v-on="$listeners"
    >
  </div>
</template>
<script>
export default {
  inheritAttrs: false,
}
</script>

<template>
  <div>
    <input
      v-bind="$attrs"
    >
  </div>
</template>

Async components

<script>
const SomeAsyncComp = () => import('./SomeAsyncComp.vue')

export default {
  components: {
    SomeAsyncComp,
  },
}
</script>
<script>
import { defineAsyncComponent } from 'vue'

const SomeAsyncComp = defineAsyncComponent(() => import('./SomeAsyncComp.vue'))

export default {
  components: {
    SomeAsyncComp,
  },
}
</script>

Functional components

<script>
import { h } from 'vue'

function DynamicHeading (props, context) {
  return h(`h${props.level}`, context.attrs, context.slots)
}

export default {
  components: {
    DynamicHeading,
  },
}
</script>

<template>
  <section>
    <DynamicHeading level="1">
      Hello World
    </DynamicHeading>
    <DynamicHeading level="2">
      Hello World
    </DynamicHeading>
  </section>
</template>

Removed: Events API

It's recommended not to use Event Bus pattern

import Vue from 'vue'

export const bus = new Vue()

bus.$on('event', data => {
  console.log(data)
})

bus.$emit('event', { foo: 'bar' })
import mitt from 'mitt'

export const bus = mitt()

bus.on('event', data => {
  console.log(data)
})

bus.emit('event', { foo: 'bar' })

Removed: Events API

It's recommended not to use Event Bus pattern

import Vue from 'vue'

export const bus = new Vue()

bus.$on('event', data => {
  console.log(data)
})

bus.$emit('event', { foo: 'bar' })
import mitt from 'mitt'

export const bus = mitt()

bus.on('event', data => {
  console.log(data)
})

bus.emit('event', { foo: 'bar' })
  • Component Props & Events
  • Provide / Inject
  • State management, such as Pinia

Renamed lifecycle options

export default {
  beforeDestroy () {
    
  },
  
  destroyed () {

  },
}
export default {
  beforeUnmount () {
    
  },
  
  unmounted () {

  },
}

Renamed transition classes

.v-enter,
.v-leave-to {
  opacity: 0;
}

.v-leave,
.v-enter-to {
  opacity: 1;
}
.v-enter-from,
.v-leave-to {
  opacity: 0;
}

.v-leave-from,
.v-enter-to {
  opacity: 1;
}

App not replacing target element

<!DOCTYPE html>
<html>
<body>
  <div id="app"/>
  <script type="module" src="src/main.js"></script>
</body>
</html>
<template>
  <div id="app">
    <header>
      <AppMenu />
    </header>

    <RouterView />
  </div>
</template>
<template>
  <header>
    <AppMenu />
  </header>

  <RouterView />
</template>

Learn more in migration guide

Migration build

Special version of Vue 3 with Flags to enable Vue 2 retro-compatibility on certain changes

New features in Vue 3

Composition API

(Also available in Vue 2.7)

Multi-Root components

(aka Fragments)

<script setup>
import { ref } from 'vue'

const showModal = ref(false)
</script>

<template>
  <div
    class="my-class"
    v-bind="$attrs"
  >
    <button @click="showModal = true">
      Open modal
    </button>
  </div>
  
  <BaseModal v-if="showModal">
    ...
  </BaseModal>
</template>

Emits option

<script setup>
const emit = defineEmits([
  'update',
])

function update () {
  emit('update')
}
</script>

<template>
  <button @click="update()">
    Open modal
  </button>
</template>
<script>
export default {
  emits: [
    'update',
  ],
  
  methods: {
    update () {
      this.$emit('update')
    },
  },
}
</script>

<template>
  <button @click="update()">
    Open modal
  </button>
</template>

SFC CSS Variables

(Also available in Vue 2.7)

<script setup>
import { ref, reactive } from 'vue'
  
const roses = ref('red')
const poem = reactive({
  violets: 'blue',
})
</script>

<template>
  <div class="my-class">Roses</div>
  <div class="my-other-class">Violets</div>
</template>

<style scoped>
.my-class {
  color: v-bind(roses);
}

.my-other-class {
  color: v-bind('poem.violets');
}
</style>

New Scoped Style Selectors

Teleport

move content to an element outside of the app

<template>
  <div
    class="my-class"
    v-bind="$attrs"
  >
    <button @click="showModal = true">
      Open modal
    </button>
  </div>
  
  <Teleport to="body">
    <BaseModal v-if="showModal">
      ...
    </BaseModal>
  </Teleport>
</template>

Safe Teleport

experimental components to target in-app elements (soon in core)

<template>
  <header>
    <RouterLink to="/">
      Home
    </RouterLink>
    
    <nav class="my-toolbar">
      <TeleportTarget id="toolbar" />
    </nav>
  </header>
  
  <RouterView />
</template>
<template>
  <div class="my-messages">
    <SafeTeleport to="#toolbar">
      <button>
        Export messages
      </button>
    </SafeTeleport>
  </div>
</template>

Suspense

(experimental)

<!-- UserList.vue -->

<script setup>
const users = await fetch('...')
  .then(r => r.json())
</script>

<template>
  <User
    v-for="user of users"
    :key="user.id"
    :user="user"
  />
</template>
<template>
  <Suspense>
    <Dashboard />

    <template #fallback>
      Loading...
    </template>
  </Suspense>
</template>
<template>
  <div class="dashboard">
    <UsersList />
  </div>
</template>

Components

Props

<template>
  {{ item.title }}
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  
  methods: {
    doSomething () {
      console.log(this.item)
    },
  },
}
</script>
<MyComponent
  v-bind:item="{ title: 'Hello '}"
/>

<MyComponent
  :item="{ title: 'Hello '}"
/>

Default Prop value

<script>
export default {
  props: {
    item: {
      type: Object,
      default: null,
    },
    
    otherItem: {
      type: Object,
      default: () => ({
        foo: 'bar',
      }),
    },
    
    answer: {
      type: Number,
      default: 42,
    },
    
    myCallback: {
      type: Function,
      default: () => () => { /* ... */ },
    },
  },
}
</script>

Multiple possible prop types

<script>
export default {
  props: {
    item: {
      type: [Object, Array],
    },
    
    other: {
      type: [String, Number],
    },
    
    answer: {
      type: [Object, Array, String, Number],
    },
  },
}
</script>

Events

<template>
  Hello!
  
  <button v-on:click="onClick">
    Click me
  </button>
</template>

<script>
export default {
  emits: [
    'update-item',
  ],
  
  methods: {
    onClick (event) {
      this.$emit('update-item', 'toto', 42)
    },
  },
}
</script>
<MyComponent
  v-for="item of items"
  v-bind:key="item.id"
  v-on:update-item="(param1, param2) => updateItem(item)"
/>

Communication Flow

<template>
  Hello!
  
  <button v-on:click="onClick">
    Click me
  </button>
</template>

<script>
export default {
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  
  emits: [
    'update-item',
  ],
  
  methods: {
    onClick (event) {
      this.$emit('update-item')
    },
  },
}
</script>
<MyComponent
  v-for="item of items"
  v-bind:key="item.id"
  v-bind:item="item"
  v-on:update-item="updateItem(item)"
/>

Props down

Events up

v-model on Components

<script>
export default {
  props: {
    modelValue: {
      type: String,
      required: true,
    },
  },
  
  emits: [
    'update:modelValue',
  ],
}
</script>
<MyComponent
  v-model="myText"
/>

<MyComponent
  :modelValue="myText"
  @update:modelValue="myText = $event"
/>

v-model on Components props

<script>
export default {
  props: {
    title: {
      type: String,
      default: '',
    },
    
    content: {
      type: String,
      default: '',
    },
  },
  
  emits: [
    'update:title',
    'update:content',
  ],
}
</script>
<MyComponent
  v-model:title="myTitle"
  v-model:content="myContent"
/>

<MyComponent
  v-bind:title="myTitle"
  v-on:update:title="myTitle = $event"
  v-bind:content="myContent"
  v-on:update:content="myContent = $event"
/>

Slots

<template>
  <h2>
    Title
  </h2>
  
  <div>
    Content:<br>
    <slot />
  </div>
</template>
<MyComponent>
  Hello
</MyComponent>

v-slot

<template>
  <h2>
    Title
  </h2>
  
  <div>
    Content:<br>
    <slot />
  </div>
</template>
<MyComponent>
  <template v-slot:default>
    Hello
  </template>
</MyComponent>

Default slot content

<template>
  <h2>
    Title
  </h2>
  
  <div>
    Content:<br>
    <slot>
      Default content
    </slot>
  </div>
</template>
<MyComponent />

Named slot

<template>
  <h2>
    <slot name="title" />
  </h2>
  
  <div>
    Content:<br>
    <slot>
      Default content
    </slot>
  </div>
</template>
<MyComponent>
  <template v-slot:default>
    Hello
  </template>
  
  <template v-slot:title>
    My own title
  </template>
</MyComponent>
<MyComponent>
  <template #default>
    Hello
  </template>
  
  <template #title>
    My own title
  </template>
</MyComponent>

Dynamic components

<template>
  Dynamic component:
  
  <component
    :is="condition ? 'MyComponent' : 'AnotherComponent'"
    v-bind:title="'Some title'"
  />
  
  Dynamic HTML element:
  
  <component
    :is="condition ? 'div' : 'span'"
    v-bind:title="'Some title'"
  />
</template>

Async components

<script>
import { defineAsyncComponent } from 'vue'
  
const MyAsyncComponent = defineAsyncComponent(() => import('./MyAsyncComponent.vue'))
  
export default {
  component: MyAsyncComponent,
}
</script>

<template>
  <MyAsyncComponent v-if="someCondition" />
</template>

Async components

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from './views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    },
    {
      path: '/me/bookings',
      name: 'my-bookings',
      // route level code-splitting
      // this generates a separate chunk (MyBookings.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: () => import('./views/MyBookings.vue'),
    },
    {
      path: '/shop/:shopId/book',
      name: 'create-booking',
      component: () => import('./views/CreateBooking.vue'),
    },
  ],
})

export default router

Provide/Inject

Provide/Inject

Provide/Inject

export default {
  data() {
    return {
      message: 'hello!'
    }
  },
  provide() {
    // use function syntax so that we can access `this`
    return {
      message: this.message
    }
  }
}
export default {
  inject: [
    'message',
  ],
  
  created() {
    console.log(this.message) // injected value
  }
}

TypeScript

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": "./",
    "target": "esnext",
    "useDefineForClassFields": true,
    "module": "esnext",
    "moduleResolution": "node",
    "isolatedModules": true,
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "paths": {
      "@/*": ["src/*"]
    },
    "lib": ["esnext", "dom", "dom.iterable", "scripthost"],
    "skipLibCheck": true
  },
  "include": ["vite.config.*", "env.d.ts", "src/**/*", "src/**/*.vue"]
}

Take Over Mode

defineComponent

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

export default defineComponent({
  // options here
})
</script>

<template>
  Hello from TypeScript
</template>

defineComponent

import { defineComponent } from 'vue'

export default defineComponent({
  // type inference enabled
  props: {
    name: String,
    msg: { type: String, required: true }
  },

  data() {
    return {
      count: 1
    }
  },

  mounted() {
    this.name // type: string | undefined
    this.msg // type: string
    this.count // type: number
  }
})

PropType

import { defineComponent, PropType } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

export default defineComponent({
  props: {
    item: {
      type: Object as PropType<MyItem>,
      required: true,
    },
  },

  mounted() {
    this.item // type: MyItem
  }
})

Typing Events

import { defineComponent } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

export default defineComponent({
  emits: {
    'update-item': (item: MyItem) => true,
  },

  mounted () {
    this.$emit('update-item', {
      id: '123',
      label: 'foo',
      price: 123.45,
    })
  },
})

TypeScript in the template

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

export default defineComponent({
  props: {
    count: {
      type: [Number, String],
      required: true,
    },
  },
})
</script>

<template>
  {{ count.toFixed(2) }}
</template>

TypeScript in the template

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

export default defineComponent({
  props: {
    count: {
      type: [Number, String],
      required: true,
    },
  },
})
</script>

<template>
  {{ (count as number).toFixed(2) }}
</template>

Global Properties

// global.d.ts

import axios from 'axios'

declare module 'vue' {
  interface ComponentCustomProperties {
    $http: typeof axios
    $i18n: (key: string) => string
  }
}
import axios from 'axios'

app.config.globalProperties.$http = axios

app.config.globalProperties.$i18n = (id: string) => {
  // ...
}

Global Components

// global.d.ts

import BaseButton from '@/components/base/BaseButton.vue'

declare module 'vue' {
  export interface GlobalComponents {
    BaseButton: typeof BaseButton
  }
}

Type-Check Vue components

Emit .d.ts files from Vue components

Composition API

Why the Composition API?

Component readability as it grows

Code Reusing Patterns have drawbacks

Limited TypeScript support

Options API Limitations:

export default {
  data () {
    return {
      searchText: '',
    }
  },
  
  computed: {
    filteredItems () {
      // ...
    },
  },
}

Component readability as it grows

Search ▶

Search ▶

export default {
  data () {
    return {
      searchText: '',
      sortBy: 'name',
    }
  },
  
  computed: {
    filteredItems () {
      // ...
    },
    
    sortedItems () {
      // ...
    },
  },
}

Component readability as it grows

Search ▶

Search ▶

Sort ▶

Sort ▶

Features are organized by component options

Organized by component options

Organized by features

With the Composition API

export default {
  data () {
    return {
      searchText: '',
      sortBy: 'name',
    }
  },
  
  computed: {
    filteredItems () {
      // ...
    },
    
    sortedItems () {
      // ...
    },
  },
}

Search ▶

Search ▶

Sort ▶

Sort ▶

With the Composition API

Search ▶

Sort ▶

export default {
  setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)
  },
}

With the Composition API

Search ▶

Sort ▶

export default {
  setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)
  },
}

Organized by feature

With the Composition API

Search ▶

Sort ▶

export default {
  setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)

    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
}

With the Composition API

Search ▶

Sort ▶

export default {
  setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)

    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
}

Expose to the template

With the Composition API

Search ▶

Sort ▶

export default {
  setup () {
    const { searchText, filteredItems } = useSearch()
    const { sortBy, sortedItems } = useSort(filteredItems)
    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
}

// Search feature
function useSearch (items) {
  const searchText = ref('')
  const filteredItems = computed(() => /* ... */)
  return { searchText, filteredItems }
}

// Sort feature
function useSort (items) {
  const sortBy = ref('name')
  const sortedItems = computed(() => /* ... */)
  return { sortBy, sortedItems }
}

With the Composition API

Search ▶

Sort ▶

export default {
  setup () {
    const { searchText, filteredItems } = useSearch()
    const { sortBy, sortedItems } = useSort(filteredItems)
    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
}

// Search feature
function useSearch (items) {
  const searchText = ref('')
  const filteredItems = computed(() => /* ... */)
  return { searchText, filteredItems }
}

// Sort feature
function useSort (items) {
  const sortBy = ref('name')
  const sortedItems = computed(() => /* ... */)
  return { sortBy, sortedItems }
}

Extract features into Composition Functions

Other Code Reuse Patterns have drawbacks

Other Code Reuse Patterns

Mixins

Mixin Factories

Scoped Slots

Mixins

export default {
  data () {
    return {
      searchText: '',
      sortBy: 'name',
    }
  },
  
  computed: {
    filteredItems () {
      // ...
    },
    
    sortedItems () {
      // ...
    },
  },
}

Mixins

const searchMixin = {
  data () {
    return { searchText: '' }
  },
  computed: {
    filteredItems () { /* ... */ },
  },
}

const sortMixin = {
  data () {
    return { sortBy: 'name' }
  },
  computed: {
    sortedItems () { /* ... */ },
  },
}


export default {
  mixins: [ searchMixin, sortMixin ],
}

Mixins

const searchMixin = {
  data () {
    return { searchText: '' }
  },
  computed: {
    filteredItems () { /* ... */ },
  },
}

const sortMixin = {
  data () {
    return { sortBy: 'name' }
  },
  computed: {
    sortedItems () { /* ... */ },
  },
}


export default {
  mixins: [ searchMixin, sortMixin ],
}

Organized by feature

Conflict Prone

Unclear relationships

Not easily reusable

Mixin Factories

Functions that return a mixin

Mixin Factories

function searchMixinFactory ({ ... }) {
  return {
    data () {
      return { searchText: '' }
    },
    computed: {
      filteredItems () { /* ... */ },
    },
  }
}

function sortMixinFactory ({ ... }) {
  return {
    data () {
      return { sortBy: 'name' }
    },
    computed: {
      sortedItems () { /* ... */ },
    },
  }
}

export default {
  mixins: [ searchMixinFactory(), sortMixinFactory() ],
}

Mixin Factories

Organized by feature

Weak namespacing

Implicit property addition

No instance access to customize the behavior

Reusable & configurable

Clearer relationship

function searchMixinFactory ({ prefix }) {
  return {
    data () {
      return { [prefix + 'searchText']: '' }
    },
    computed: {
      [prefix + 'filteredItems'] () { /* ... */ },
    },
  }
}

function sortMixinFactory ({ ... }) {
  return {
    data () {
      return { sortBy: 'name' }
    },
    computed: {
      sortedItems () { /* ... */ },
    },
  }
}

export default {
  mixins: [ searchMixinFactory({prefix:'m'}), sortMixinFactory() ],
}

Scoped slots

Scoped slots

<script>
export default {
  // Search feature here
}
</script>

<template>
  <div>
    <slot v-bind="{ searchText, filteredItems }" />
  </div>
</template>
<script>
export default {
  // Sort feature here
}
</script>

<template>
  <div>
    <slot v-bind="{ sortBy, sortedItems }" />
  </div>
</template>
<SearchService
  v-slot="searchProps"
>
  <SortService
    :item="searchProps.filteredItems"
    v-slot="sortProps"
  >
    <li v-for="item of sortProps.sortedItems">
      ...
    </li>
  </SearchService>
</SearchService>
SearchService.vue
SortService.vue

Scoped slots

<script>
export default {
  // Search feature here
}
</script>

<template>
  <div>
    <slot v-bind="{ searchText, filteredItems }" />
  </div>
</template>
<script>
export default {
  // Sort feature here
}
</script>

<template>
  <div>
    <slot v-bind="{ sortBy, sortedItems }" />
  </div>
</template>
<SearchService
  v-slot="searchProps"
>
  <SortService
    :item="searchProps.filteredItems"
    v-slot="sortProps"
  >
    <li v-for="item of sortProps.sortedItems">
      ...
    </li>
  </SearchService>
</SearchService>

Solves mixin problems

Increased indentation

Lost of configuration

Less flexible

Less performant

Composition API

Composition API

export default {
  data () {
    return {
      searchText: '',
      sortBy: 'name',
    }
  },
  
  computed: {
    filteredItems () {
      // ...
    },
    
    sortedItems () {
      // ...
    },
  },
}

Composition API

export default {
  setup () {
    const { searchText, filteredItems } = useSearch()
    const { sortBy, sortedItems } = useSort(filteredItems)
    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
}

// Search feature
function useSearch (items) {
  const searchText = ref('')
  const filteredItems = computed(() => /* ... */)
  return { searchText, filteredItems }
}

// Sort feature
function useSort (items) {
  const sortBy = ref('name')
  const sortedItems = computed(() => /* ... */)
  return { sortBy, sortedItems }
}

Composition API

export default {
  setup () {
    const { searchText, filteredItems } = useSearch()
    const { sortBy, sortedItems } = useSort(filteredItems)
    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
}

// Search feature
function useSearch (items) {
  const searchText = ref('')
  const filteredItems = computed(() => /* ... */)
  return { searchText, filteredItems }
}

// Sort feature
function useSort (items) {
  const sortBy = ref('name')
  const sortedItems = computed(() => /* ... */)
  return { sortBy, sortedItems }
}

Less code

Familiar vars & functions

Extremely flexible

Tooling friendly

New concepts to learn

ref()

import { ref, watch } from 'vue'

export default {
  setup () {
    const count = ref(0)
    
    function increment () {
      count.value++
    }
    
    watch(count, value => {
      console.log('count changed', value)
    })
    
    return {
      count,
      increment,
    }
  },
}

Why do we need count.value?

Refs are Unwrapped in Template

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

export default {
  setup () {
    const count = ref(0)

    function increment() {
      count.value++
    }

    return {
      count,
      increment,
    }
  }
}
</script>

<template>
  <button @click="increment">
    {{ count }} <!-- no .value needed -->
  </button>
</template>

reactive()

import { reactive, watch } from 'vue'

export default {
  setup () {
    const countState = reactive({
      count: 0,
    })
    
    function increment () {
      countState.count++
    }
    
    watch(() => countState.count, value => {
      console.log('count changed', countState.count)
    })
    
    return {
      countState,
      increment,
    }
  },
}

computed()

import { ref, computed, watch } from 'vue'

export default {
  setup () {
    const count = ref(0)
    
    const double = computed(() => count.value * 2)
    
    watch(double, value => {
      console.log('double changed', value)
    })
    
    return {
      double,
    }
  },
}

Props

import { ref, watch } from 'vue'

export default {
  props: {
    initialCount: {
      type: Number,
      default: 0,
    },
  },

  setup (props) {
    const count = ref(props.initialCount)

    watch(() => props.initialCount, value => {
      count.value = value
    })

    return {
      count,
    }
  },
}

Composable

// Search feature
function useSearch (items: Ref<MyItem[]>) {
  const searchText = ref('')
  const filteredItems = computed(() => items.value.filter(/* ... */))
  return { searchText, filteredItems }
}

// Sort feature
function useSort (items: Ref<MyItem[]>) {
  const sortBy = ref('label')
  const sortedItems = computed(() => items.value.slice().sort(/* ... */))
  return { sortBy, sortedItems }
}
import { toRefs } from 'vue'
import { useSearch, useSort } from './my-composables'

export default {
  setup (props) {
    const { items } = toRefs(props)
    
    const { search, filteredItems } = useSearch(items)
    const { sortBy, sortedItems } = useSort(filteredItems)
    
    return {
      search,
      sortBy,
      sortedItems,
    }
  }
}

Setup script

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

export default defineComponent({
  setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)

    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
})
</script>

<template>
  <!-- ... -->  
</template>

Setup script

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

export default defineComponent({
  setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)

    return {
      searchText,
      sortBy,
      sortedItems,
    }
  },
})
</script>

<template>
  <!-- ... -->  
</template>

Setup script

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

// export default defineComponent({
//   setup () {
    // Search feature
    const searchText = ref('')
    const filteredItems = computed(() => /* ... */)
    
    // Sort feature
    const sortBy = ref('name')
    const sortedItems = computed(() => /* ... */)

    return {
      searchText,
      sortBy,
      sortedItems,
    }
//   },
// })
</script>

<template>
  <!-- ... -->  
</template>

Setup script

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

// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)

// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)

return {
  searchText,
  sortBy,
  sortedItems,
}
</script>

<template>
  <!-- ... -->  
</template>

Setup script

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

// Search feature
const searchText = ref('')
const filteredItems = computed(() => /* ... */)

// Sort feature
const sortBy = ref('name')
const sortedItems = computed(() => /* ... */)
</script>

<template>
  <!-- ... -->  
</template>

defineProps

<script lang="ts" setup>
const props = defineProps({
  item: {
    type: Object as PropType<MyItem>,
    required: true,
  },
})

console.log(props.item)
</script>

<template>
  {{ item }}
</template>

defineProps

<script lang="ts" setup>
const props = defineProps<{
  item: MyItem
}>()

console.log(props.item)
</script>

<template>
  {{ item }}
</template>

defineEmits

import { PropType } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

const props = defineProps({
  item: {
    type: Object as PropType<MyItem>,
    required: true,
  },
})

const emit = defineEmits({
  'update-item': (item: MyItem) => true,
})

function updateItem () {
  emit('update-item', {
    ...props.item,
    price: props.item.price + 1,
  })
}

defineEmits

import { PropType } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

const props = defineProps<{
  item: MyItem
}>()

const emit = defineEmits<{
  (e: 'update-item', item: MyItem): void
}>()

function updateItem () {
  emit('update-item', {
    ...props.item,
    price: props.item.price + 1,
  })
}

React Hooks vs Composition API

React Hooks

  • Hooks are call-order sensitive and cannot be conditional
  • Need to declare dependencies and manage closures
  • Need manual usage of `useMemo` + manual deps
  • Unnecessary update caused by event listeners by default

Vue Composition API

  • Setup and composables are called only once
  • No stale closure
  • No need to declare dependencies manual thanks to the reactivity system
  • Automatic fine-grained child updates

Composition API + TypeScript

<script lang="ts" setup>
import type { PropType } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

const props = defineProps({
  item: {
    type: Object as PropType<MyItem>,
    required: true,
  },
})

props.item // MyItem
</script>

defineProps

<script lang="ts" setup>
interface MyItem {
  id: string
  label: string
  price: number
}

const props = defineProps<{
  item: MyItem
}>()

props.item // MyItem
</script>

defineProps

<script lang="ts" setup>
interface MyItem {
  id: string
  label: string
  price: number
}

const props = defineProps<{
  item: MyItem
  optionalLabel?: string
}>()

props.item // MyItem
props.optionalLabel // string | undefined
</script>

defineProps

<script lang="ts" setup>
interface MyItem {
  id: string
  label: string
  price: number
}

const props = withDefaults(defineProps<{
  item: MyItem
  optionalLabel?: string
}>(), {
  optionalLabel: 'default label',
})

props.item // MyItem
props.optionalLabel // string
</script>

defineProps

<script lang="ts" setup>
import { onMounted } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

const emit = defineEmits({
  'update-item': (item: MyItem) => true,
})

onMounted(() => {
  emit('update-item', {
    id: '123',
    label: 'foo',
    price: 123.45,
  })
})
</script>

defineEmits

<script lang="ts" setup>
import { onMounted } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

const emit = defineEmits<{
  (e: 'update-item', item: MyItem): void
}>()

onMounted(() => {
  emit('update-item', {
    id: '123',
    label: 'foo',
    price: 123.45,
  })
})
</script>

defineEmits

<script lang="ts" setup>
import { ref, reactive } from 'vue'
import type { Ref } from 'vue'

interface MyItem {
  id: string
  label: string
  price: number
}

const value = ref<string | number>('2022')
const state = reactive<{ item: MyItem | null }>({
  item: null,
})

function foo (someRef: Ref<string | number>) {
  console.log(someRef)
}

function bar (someState: { item: MyItem | null }) {
  console.log(someState)
}

foo(value)
bar(state)
</script>

ref & reactive

<script lang="ts" setup>
import { provide } from 'vue'
import { key } from './key'

provide(key, {
  id: 'abc',
  label: 'Computer',
  price: 1_000,
})
</script>

Provide / Inject

// key.ts

import type { InjectionKey } from 'vue'

export interface MyItem {
  id: string
  label: string
  price: number
}

export const key = Symbol() as InjectionKey<MyItem>
<script lang="ts" setup>
import { inject } from 'vue'
import { key } from './key'

const item = inject(key) // MyItem | undefined
</script>

Provide / Inject

<script lang="ts" setup>
import { inject } from 'vue'
import { key } from './key'
import type { MyItem } from './key'

const item = inject(key) // MyItem | undefined

const item2 = inject<MyItem>('some-string-key') // MyItem | undefined

const sureItem = inject('some-string-key') as MyItem // MyItem

const text = inject<string>('some-other-key') // string | undefined

const textWithDefault = inject<string>('some-other-key', 'default value') // string
</script>

Template refs

<!-- MyModal.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const isContentShown = ref(false)
const open = () => (isContentShown.value = true)

defineExpose({
  open
})
</script>
<script setup lang="ts">
import MyModal from './MyModal.vue'

const modal = ref<InstanceType<typeof MyModal> | null>(null)

const openModal = () => {
  modal.value?.open()
}
</script>

State Management

Vuex

State & Mutation

import { createStore } from 'vuex'

const store = createStore({
  state () {
    return {
      count: 0
    }
  },
  mutations: {
    increment (state) {
      state.count++
    }
  }
})

app.use(store)
export default {
  methods: {
    increment() {
      this.$store.commit('increment')
      console.log(this.$store.state.count)
    },
  },
}
import { mapState, mapMutations } from 'vuex'

export default {
  computed: {
    ...mapState([
      'count',
    ]),
  },

  methods: {
    ...mapMutations([
      'increment',
    ]),
  },
}

Getters

const store = createStore({
  state: {
    todos: [
      { id: 1, text: '...', done: true },
      { id: 2, text: '...', done: false }
    ]
  },
  getters: {
    doneTodos (state) {
      return state.todos.filter(todo => todo.done)
    }
  }
})
<script>
import { mapGetters } from 'vuex'

export default {
  // ...
  computed: {
    // mix the getters into computed
    ...mapGetters([
      'doneTodos',
      // ...
    ])
  }
}
</script>

<template>
  {{ doneTodos }}
</template>

Actions

const store = createStore({
  state: {
    count: 0
  },

  mutations: {
    increment (state) {
      state.count++
    }
  },

  actions: {
    increment (context) {
      // We can do async ops and multiple commits here
      context.commit('increment')
    },
  },
})
import { mapActions } from 'vuex'

export default {
  // ...
  methods: {
    ...mapActions([
      'increment',
      // ...
    ])
  }
}

Modules

const moduleA = {
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... },
  getters: { ... },
}

const moduleB = {
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... },
}

const store = createStore({
  modules: {
    a: moduleA,
    b: moduleB,
  },
})
import { mapGetters, mapActions } from 'vuex'

export default {
  computed: {
    ...mapGetters('a', [
      // Getters from module 'a'
    ]),
    
    ...mapGetters('b', [
      // Getters from module 'b'
    ]),
  },
  
  methods: {
    ...mapActions('a', [
      // Actions from module 'a'
    ]),
    
    ...mapActions('b', [
      // Actions from module 'b'
    ]),
  },
}

Pinia

is now the recommended library

Pinia vs Vuex

  • Mutations no longer exist
  • Simple and better TypeScript support
  • No "magic" strings
  • Dynamically added stores by default
  • Flat structures instead of nested modules
    + circular dependencies possible
  • No namespacing

Pinia setup

import { createPinia } from 'pinia'

app.use(createPinia())

Pinia store

import { defineStore } from 'pinia'

export const useShopStore = defineStore('shops', {
  state: () => ({
    loading: false,
    error: null as Error | null,
    shops: [] as Shop[],
  }),
  getters: {
    shopsCount: state => state.shops.length,
  },
  actions: {
    async fetchShops () {
      this.loading = true
      this.error = null

      try {
        const response = await $fetch('/shops')
        this.shops = response.data
      } catch (e: any) {
        this.error = e
      } finally {
        this.loading = false
      }
    },
  },
})

Setup store

import { defineStore } from 'pinia'

export const useShopStore = defineStore('shops', () => {
  const loading = ref(false)
  const error = ref<Error | null>(null)
  const shops = ref<Shop[]>([])
  
  const shopsCount = computed(() => state.shops.length)
  
  async function fetchShops () {
    loading.value = true
    error.value = null

    try {
      const response = await $fetch('/shops')
      shops.value = response.data
    } catch (e: any) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  return {
    loading,
    error,
    shops,
    fetchShops,
  }
})

Store usage

<script lang="ts" setup>
import BaseLoading from './base/BaseLoading.vue'
import BaseError from './base/BaseError.vue'
import ShopItem from './ShopItem.vue'
import { useShopStore } from '@/stores/shop'

const shopsStore = useShopStore()
shopsStore.fetchShops()
</script>

<template>
  <BaseLoading v-if="shopsStore.loading">
    Loading...
  </BaseLoading>

  <BaseError v-if="shopsStore.error">
    {{ shopsStore.error.message }}
  </BaseError>

  <ShopItem
    v-for="shop in shopsStore.shops"
    :key="shop.id"
    :shop="shop"
  />
</template>

Testing

Guiding principles

Don't test the implementation details

Only test inputs and outputs

Arrange, Act, Assert

Don't test implementation details

Inputs Examples
Interations Clicking, typing... any "human" interaction
Props The arguments a component receives
Data streams Data incoming from API calls, data subscriptions…
Outputs Examples
DOM elements Any observable node rendered to the document
Events Emitted events (using $emit)
Side Effects Such as console.log or API calls

Everything else is implementation details.

Testing a component

import { describe, it, expect } from 'vitest'

import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'

describe('HelloWorld', () => {
  it('renders properly', () => {
    const wrapper = mount(HelloWorld, {
      props: {
        msg: 'Hello Vitest',
      },
    })
    expect(wrapper.text()).toContain('Hello Vitest')
  })
})

Input

describe('HelloWorld', () => {
  it('handles input', async () => {
    const wrapper = mount(HelloWorld)
    
    await wrapper.get('input').setValue('Hello Vitest')
    
    expect(wrapper.text()).toContain('Hello Vitest')
  })
})

Trigger events

describe('HelloWorld', () => {
  it('increments', async () => {
    const wrapper = mount(HelloWorld)
    
    await wrapper.get('button').trigger('click')
    
    expect(wrapper.text()).toContain('2')
  })
})

Mocking module

import { vi } from 'vitest'

vi.mock('lodash/debounce', () => {
  return {
    default: (cb: any) => cb,
  }
})

Mocking network

import { afterAll, afterEach, beforeAll, describe } from 'vitest'
import { setupServer } from 'msw/node'
import { rest } from 'msw'
import { shops } from './fixtures/shops'

const baseURL = 'http://localhost:4000'

const server = setupServer(
  rest.get(`${baseURL}/shops`, (req, res, ctx) => {
    if (req.url.searchParams.get('q') === 'cat') {
      return res(ctx.status(200), ctx.json(shops.slice(0, 1)))
    }
    return res(ctx.status(200), ctx.json(shops))
  }),
  // other handlers...
)

describe('shop store', () => {
  beforeAll(() => {
    server.listen({ onUnhandledRequest: 'error' })
  })

  afterEach(() => {
    server.resetHandlers()
  })

  afterAll(() => {
    server.close()
  })

  // tests here...
})

Mocking pinia

import { describe, expect, test, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { useShopStore } from '@/stores/shop'
import { shops } from './fixtures/shops'

describe('ShopsList', () => {
  test('displays shop items', async () => {
    const wrapper = mount(ShopsList, {
      global: {
        plugins: [createTestingPinia({
          createSpy: () => vi.fn(),
        })],
      },
    })

    const store = useShopStore()
    store.shops = shops
    await flushPromises()

    expect(wrapper.findAllComponents(ShopItem).length).toBe(3)
    expect(store.fetchShops).toHaveBeenCalled()
  })
})

Testing pinia store

import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test } from 'vitest'
import { useShopStore } from '../shop'

describe('shop store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  test('loads shops', async () => {
    const store = useShopStore()
    const promise = store.fetchShops()
    expect(store.loading).toBe(true)
    await promise
    expect(store.shops.length).toBe(3)
    expect(store.loading).toBe(false)
    expect(store.error).toBe(null)
  })
})

SSR

Server

App

Render

<div>
  Hello world!
</div>

Server

App

Render

<html>
<ul>
  <li>James Holden</li>
  <li>Naomi Nagata</li>
</ul>
</html>

Database

Read

Browser

Request page

Send HTML

<html>
<a>Roccinante crew</a>
</html>
<html>
<ul>
  <li>James Holden</li>
  <li>Naomi Nagata</li>
</ul>
</html>

Replace the whole page

Server-Side Only App

Server-Side Only App

Request page

Receiving page

Received page

Click button

Request page

Database Read

Receiving page

Received page

Examples

Server

API

Database

Read

Browser

Request page

Send JSON

<html>
<div id="app">
<a>Roccinante crew</a>
</div>
</html>
<html>
<div id="app">
<ul>
  <li>James Holden</li>
  <li>Naomi Nagata</li>
</ul>
</div>
</html>

Single Page App

CDN

HTML

JS

Request API

Request script

<html>
<div id="app"></div>
</html>




<html>
<div id="app">
<div class="loading"/>
</div>
</html>

App

Single Page App

Request page

Received JS

Click button

Change route
Request API

Database Read

Received JSON

Received page

Request JS

Examples

Server

API

Database

Read

Browser

Request page

Send JSON

<html>
<div id="app">
<a>Roccinante crew</a>
</div>
</html>
<html>
<div id="app">
<ul>
  <li>James Holden</li>
  <li>Naomi Nagata</li>
</ul>
</div>
</html>

Universal App

CDN

JS

Request API

Request script

<html>
<div id="app">
<div class="loading"/>
</div>
</html>

Hydration

Server

App

JS

App

Universal App

Request page

Received JS

Click button

Change route
Request API

Database Read

Received JSON

Receiving page

Received page

Request JS

Server

API

Database

Read

Browser

Request page

Send JSON

<html>
<div id="app">
<ul>
  <li>James Holden</li>
  <li>Naomi Nagata</li>
</ul>
</div>
</html>

Universal App

CDN

JS

Request API

Request script

Server

App

Hydration

JS

App

Universal App

Request page

Received page

Request JS

Received JS

Database Read

Receiving page

Examples

next-black Created with Sketch.

Nuxt

What's inside?

Vite (or Webpack)

Node.js Server Framework

h3

HTTP Server

Bundler

Nuxi

Command-line interface

pnpx nuxi init <project-name>

assets

processed by bundler

components

auto-imported

composables

auto-imported

layouts

wraps page content (<NuxtLayout>)

middleware

executed during route navigation

pages

generates routes

plugins

app setup

public

static assets

server

API (backend)

utils

auto-imported

app.vue

main component

nuxt.config.ts

Nuxt configuration

Vue.js Workshop

By Guillaume Chau