Seven advanced Vue 3 techniques worth knowing

Composables, Teleport, Suspense, custom directives, render functions, provide/inject and plugins, with the code for each.

Vue 3 has been part of everyday frontend work for a while now. It's intuitive and pleasant to build with, and the basics get you a long way. These are seven techniques past that point, all of which I've found useful in my own projects.

1. The Composition API past setup()

You might object that the Composition API isn't advanced anymore, and that's fair. Most of us are comfortable with setup() and reactive references. The part that often goes unused is extracting logic into composables.

Think of a composable as a function that encapsulates stateful logic. Components get leaner because they only handle rendering, logic is grouped by feature rather than by option, and the same logic can be shared without the problems mixins used to cause.

Say you have a feature that fetches data and tracks loading and error states. Instead of repeating that in every component:

// composables/useFetch.js
import { ref, onMounted } from "vue";

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(true);

  async function fetchData() {
    loading.value = true;
    error.value = null;
    try {
      const res = await fetch(url);
      if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status}`);
      }
      data.value = await res.json();
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  }

  onMounted(fetchData);

  return { data, error, loading, fetchData };
}

Any component can then use it:

<template>
  <div>
    <div v-if="loading">Loading posts...</div>
    <div v-else-if="error">Error: {{ error.message }}</div>
    <ul v-else>
      <li v-for="post in data" :key="post.id">{{ post.title }}</li>
    </ul>
  </div>
</template>

<script setup>
import { useFetch } from "@/composables/useFetch";

const { data, error, loading } = useFetch(
  "https://jsonplaceholder.typicode.com/posts"
);
</script>

2. Teleport, for elements that need to escape

If you've fought z-index wars while trying to render modals, tooltips or notifications from inside a deeply nested component, Teleport is the answer. It renders part of your template somewhere else in the DOM, outside the component hierarchy, while keeping reactivity with your component's state.

It fits modals and dialogs, toasts and notifications, tooltips and dropdowns.

<!-- In your App.vue or main layout file -->
<body>
  <div id="app"></div>
  <div id="modals-container"></div>
  <!-- This is our teleport target -->
</body>
<!-- components/MyModal.vue -->
<template>
  <Teleport to="#modals-container">
    <div v-if="isOpen" class="modal-backdrop" @click="close">
      <div class="modal-content" @click.stop>
        <h3>My Awesome Modal</h3>
        <p>This modal content is rendered outside the component!</p>
        <button @click="close">Close</button>
      </div>
    </div>
  </Teleport>
</template>

<script setup>
import { ref } from "vue";

const isOpen = ref(false);

const open = () => (isOpen.value = true);
const close = () => (isOpen.value = false);

// Expose open/close for parent component to use
defineExpose({ open, close });
</script>

<style scoped>
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1000;
}
.modal-content {
  background: white;
  padding: 20px;
  border-radius: 8px;
  min-width: 300px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
</style>

Wherever MyModal is used, its content lands in #modals-container, which keeps styling conflicts out of the picture.

3. Suspense for async loading states

Waiting on data or asynchronously loaded components makes a UI feel sluggish. Suspense coordinates async dependencies in a component tree and shows a fallback while they resolve, so users see a loading state rather than a blank area or a broken layout.

A component that fetches on mount:

<!-- components/AsyncDataDisplay.vue -->
<template>
  <div>
    <h2>Data from API:</h2>
    <p>{{ data.title }}</p>
  </div>
</template>

<script setup>
import { ref } from "vue";

const data = ref(null);

// Simulate an async data fetch
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");
data.value = await res.json();
</script>

Wrapped in Suspense:

<template>
  <div>
    <h1>My App</h1>
    <Suspense>
      <!-- Main content that might be async -->
      <AsyncDataDisplay />

      <!-- Fallback content while async components are loading -->
      <template #fallback>
        <div>Loading awesome data...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { defineAsyncComponent } from "vue";

// Define an async component
const AsyncDataDisplay = defineAsyncComponent(
  () => import("./components/AsyncDataDisplay.vue")
);
</script>

While AsyncDataDisplay resolves the await in its setup block, the fallback shows. When it finishes, the real content replaces it.

4. Custom directives for DOM work

Sometimes a component isn't the right tool. For low-level DOM manipulation, or reusable behaviour that isn't tied to a specific component, custom directives give you direct access to the element they're bound to. Things like v-focus, v-tooltip or v-lazy-load.

// main.js or a dedicated directives file
import { createApp } from "vue";
import App from "./App.vue";

const app = createApp(App);

app.directive("focus", {
  mounted(el) {
    el.focus();
  },
});

app.mount("#app");

Used like this:

<template>
  <input v-focus type="text" placeholder="I will be focused on mount" />
</template>

The input gains focus when the component mounts. Directives can hook into created, mounted, updated and unmounted to control behaviour across the element's lifecycle.

5. Render functions and JSX

For almost everything, Vue's template syntax is the right choice: declarative, readable and compiled efficiently. Render functions and JSX are for the rare cases where you need programmatic control over rendering, such as a highly dynamic table generator or a library component whose structure changes with complex props.

You probably won't reach for this unless the structure varies a lot, you're writing a UI library and need maximum flexibility, or you'd rather write components entirely in JavaScript or TypeScript.

// components/DynamicHeading.vue
import { h } from "vue";

export default {
  props: {
    level: {
      type: Number,
      required: true,
      validator: (val) => val >= 1 && val <= 6,
    },
  },
  setup(props) {
    return () => h(`h${props.level}`, `This is a level ${props.level} heading`);
  },
};

The same thing with JSX, which needs the Vue JSX Babel setup:

// components/DynamicHeadingJSX.vue
export default {
  props: {
    level: {
      type: Number,
      required: true,
      validator: (val) => val >= 1 && val <= 6,
    },
  },
  setup(props) {
    const HeadingTag = `h${props.level}`;
    return () => (
      <HeadingTag>This is a level {props.level} heading (with JSX)</HeadingTag>
    );
  },
};

6. Provide and inject

For genuinely global state, Pinia is the answer. Provide and inject are for the middle case: data or utilities that need to reach deep into a component tree without passing props through every level.

They suit theme information, user preferences, utility functions, and data that rarely changes but is relevant to a whole subtree.

<!-- components/GrandparentComponent.vue -->
<template>
  <div>
    <h1>Grandparent</h1>
    <button @click="toggleTheme">Toggle Theme</button>
    <ChildComponent />
  </div>
</template>

<script setup>
import { provide, ref } from "vue";
import ChildComponent from "./ChildComponent.vue";

const theme = ref("light");

const toggleTheme = () => {
  theme.value = theme.value === "light" ? "dark" : "light";
};

provide("appTheme", theme); // Provide the reactive theme
provide("toggleThemeFunction", toggleTheme); // Provide a function too!
</script>
<!-- components/DeeplyNestedComponent.vue -->
<template>
  <div :class="['card', appTheme === 'dark' ? 'dark-mode' : '']">
    <p>Current theme: {{ appTheme }}</p>
    <button @click="toggleThemeFunction">Toggle Theme (from deep)</button>
  </div>
</template>

<script setup>
import { inject } from "vue";

const appTheme = inject("appTheme");
const toggleThemeFunction = inject("toggleThemeFunction"); // Inject the function
</script>

<style scoped>
.card {
  border: 1px solid #ccc;
  padding: 15px;
  margin-top: 10px;
  border-radius: 5px;
}
.dark-mode {
  background-color: #333;
  color: #eee;
  border-color: #555;
}
</style>

DeeplyNestedComponent gets both the theme and the function without GrandparentComponent passing anything through ChildComponent.

7. Global properties and plugins

Sometimes something needs to be available across the whole application: an HTTP instance, a translation function, a shared utility.

The simplest route is app.config.globalProperties, which attaches to this in the Options API and is reachable through getCurrentInstance() in the Composition API.

// main.js
import { createApp } from "vue";
import App from "./App.vue";

const app = createApp(App);

// Attach a global property
app.config.globalProperties.$myGlobalUtil = {
  sayHello: () => console.log("Hello from global util!"),
  version: "1.0.0",
};

app.mount("#app");

In a template that's <button @click="$myGlobalUtil.sayHello()">Say Hello</button>, and in a script setup block:

<script setup>
import { getCurrentInstance } from "vue";
const { proxy } = getCurrentInstance();

proxy.$myGlobalUtil.sayHello();
console.log(proxy.$myGlobalUtil.version);
</script>

For anything larger, involving several global properties, directives, components or routing logic, write a plugin. A plugin is a function that receives the app instance and an optional options object.

// plugins/myPlugin.js
export default {
  install: (app, options) => {
    // Make a global method available
    app.config.globalProperties.$translate = (key) => {
      return key
        .split(".")
        .reduce((o, i) => (o ? o[i] : undefined), options.translations);
    };

    // Provide a global composable
    app.provide("globalStore", {
      user: "John Doe",
      settings: { theme: "dark" },
    });

    // Or register a global component
    // app.component('MyGlobalComponent', MyGlobalComponent)
  },
};
// main.js
import { createApp } from "vue";
import App from "./App.vue";
import MyPlugin from "./plugins/myPlugin";

const app = createApp(App);

const translations = {
  hello: "Bonjour",
  welcome: {
    message: "Bienvenue!",
  },
};

app.use(MyPlugin, { translations }); // Use the plugin with options
app.mount("#app");

Any component can now call $translate('welcome.message') or inject globalStore. This is the same mechanism Vue Router and Pinia use to integrate into an application.

These seven patterns cover most of the situations where the basics stop being enough. They feel more natural the more you reach for them.

Related posts