Back to journal
·2 min readVue.jsVueVue JsDesign PatternsComposition ApiComponentsBest PracticesFrontend Development

Vue Design Patterns That Actually Make Your Life Easier

Smart vs dumb components, list/list-item, composables, provide/inject, and when patterns help vs when they're overkill.

ShareCopy failed

Mid-project Vue apps get messy. Props drill through five layers. State lives in three places. You open a file and wonder who wrote it (you did, last month).

Patterns don't fix bad requirements, but they give you predictable places to put logic.

Why bother

Patterns are just repeated solutions. Vue gives you great primitives. Structure is still on you.

Good structure means less duplication, clearer refactors, and teammates who aren't scared to touch the repo.

Smart vs dumb components

Smart ones fetch data and decide what to show. Dumb ones render props.

Smart

<script setup>
import { ref, onMounted } from "vue";
import UserList from "./UserList.vue";

const users = ref([]);
const loading = ref(true);

async function fetchUsers() {
  users.value = await api.getUsers();
  loading.value = false;
}

onMounted(fetchUsers);
</script>

<template>
  <UserList :users="users" :loading="loading" />
</template>

Dumb

<script setup>
defineProps({
  users: Array,
  loading: Boolean,
});
</script>

<template>
  <div v-if="loading">Loading...</div>
  <ul v-else>
    <li v-for="user in users" :key="user.id">
      {{ user.name }}
    </li>
  </ul>
</template>

List + ListItem

Parent handles filter, sort, empty state. Child renders one row.

<!-- UsersList.vue -->
<script setup>
const props = defineProps({ users: Array });
const filter = ref("");

const filteredUsers = computed(() => props.users.filter((u) => u.name.includes(filter.value)));
</script>

<template>
  <input v-model="filter" placeholder="Find user" />
  <ul v-if="filteredUsers.length">
    <UserListItem v-for="u in filteredUsers" :key="u.id" :user="u" />
  </ul>
  <div v-else>No results</div>
</template>

Slots

When props aren't enough for flexible markup:

<template>
  <button class="btn">
    <slot></slot>
  </button>
</template>

Composables

Extract state and side effects:

export function useMouse() {
  const x = ref(0);
  const y = ref(0);
  function update(e) {
    x.value = e.pageX;
    y.value = e.pageY;
  }
  onMounted(() => window.addEventListener("mousemove", update));
  onUnmounted(() => window.removeEventListener("mousemove", update));
  return { x, y };
}

Provide / inject

Skip prop drilling for theme, locale, auth:

const themeSymbol = Symbol("theme");

export function provideTheme() {
  const theme = ref("light");
  const toggle = () => {
    theme.value = theme.value === "light" ? "dark" : "light";
  };
  provide(themeSymbol, { theme, toggle });
}

export function useTheme() {
  const injected = inject(themeSymbol);
  if (!injected) throw new Error("Theme not provided");
  return injected;
}

Bigger apps

Strategy objects for swappable behavior. Lightweight event bus for sibling chatter (don't replace Pinia with a bus everywhere). <component :is="view"> for tabs. Recursive components for trees.

Typical flow in a structured Vue application

When to stop

Tiny app? Patterns early = pain. Huge app with repeated list UIs? Patterns pay rent.

Start with smart/dumb split and composables. Add provide/inject and list patterns when prop drilling hurts. You're allowed to skip patterns you don't need yet.

ShareCopy failed