Skip to content
Back

Vue 3 Composition API Best Practice Patterns

Zhou Xunyou 8 min read frontend
Share

Vue 3’s Composition API is a fundamental redesign of the Options API, offering more flexible logic organization and reuse. This article explores the design patterns, reactivity system internals, TypeScript integration, and practical migration strategies from the Options API.

Why the Composition API

The core problem with the Options API is scattered logic. Code for a single feature is split across data, computed, methods, watch, and other options:

// Options API: search feature code is scattered everywhere
export default {
  data() {
    return {
      query: '',
      results: [],
      loading: false,
      // other features' data...
      currentPage: 1,
      pageSize: 10,
    };
  },
  computed: {
    filteredResults() { /* ... */ },
    // other features' computed...
  },
  methods: {
    async search() { /* ... */ },
    // other features' methods...
  },
  watch: {
    query(val) { /* ... */ },
    // other features' watch...
  },
};

The Composition API lets you keep all logic for one feature together:

// Composition API: all search logic in one place
function useSearch() {
  const query = ref('');
  const results = ref([]);
  const loading = ref(false);

  async function search() {
    loading.value = true;
    results.value = await fetchResults(query.value);
    loading.value = false;
  }

  watch(query, debounce(search, 300));

  return { query, results, loading, search };
}

// Usage
export default {
  setup() {
    const search = useSearch();
    const pagination = usePagination();
    return { ...search, ...pagination };
  },
};

Reactivity System Internals

Proxy-based Reactivity

Vue 3 uses ES6 Proxy instead of Vue 2’s Object.defineProperty:

// Vue 2 limitations
const obj = {};
Object.defineProperty(obj, 'name', {
  get() { /* ... */ },
  set(val) { /* ... */ },
});
// Cannot detect: new properties, array index changes, array length changes

// Vue 3 Proxy approach
const reactive = (target) => {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key); // Collect dependencies
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key); // Trigger updates
      }
      return result;
    },
    deleteProperty(target, key) {
      const hadKey = key in target;
      const result = Reflect.deleteProperty(target, key);
      if (hadKey) {
        trigger(target, key); // Detect property deletion
      }
      return result;
    },
  });
};

Choosing Between ref and reactive

import { ref, reactive, toRefs } from 'vue';

// ref: for primitives and values that need reassignment
const count = ref(0);
const user = ref({ name: 'Alice' });
user.value = { name: 'Bob' }; // Whole replacement is fine

// reactive: for complex objects that won't be reassigned
const state = reactive({
  users: [],
  loading: false,
  filters: { status: 'active' },
});
state.users.push(newUser); // Direct mutation
// state = { users: [] }; // Wrong! Cannot replace entire object

// Best practice: prefer ref in composables
function useUser() {
  const user = ref(null); // Starts null, gets assigned later
  const loading = ref(false);

  async function fetchUser(id) {
    loading.value = true;
    user.value = await api.getUser(id); // Whole replacement OK
    loading.value = false;
  }

  return { user, loading, fetchUser };
}

// Destructuring reactive objects loses reactivity — use toRefs
const state = reactive({ name: 'Alice', age: 30 });
const { name, age } = toRefs(state); // Preserves reactivity

Composable Function Design Patterns

Basic Pattern

// composables/useFetch.ts
import { ref, shallowRef, watchEffect, type Ref } from 'vue';

interface UseFetchOptions<T> {
  immediate?: boolean;
  initialValue?: T;
  refetch?: boolean;
}

interface UseFetchReturn<T> {
  data: Ref<T | undefined>;
  error: Ref<Error | null>;
  loading: Ref<boolean>;
  execute: () => Promise<void>;
}

export function useFetch<T>(
  url: Ref<string> | string,
  options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
  const data = shallowRef<T | undefined>(options.initialValue);
  const error = ref<Error | null>(null);
  const loading = ref(false);

  const execute = async () => {
    loading.value = true;
    error.value = null;
    try {
      const resolvedUrl = typeof url === 'string' ? url : url.value;
      const response = await fetch(resolvedUrl);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      data.value = await response.json();
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  };

  if (options.immediate !== false) {
    execute();
  }

  if (options.refetch && typeof url !== 'string') {
    watchEffect(() => { execute(); });
  }

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

Async State Management Pattern

// composables/useAsyncState.ts
import { ref, computed, type Ref } from 'vue';

interface State<T> {
  data: T | null;
  error: Error | null;
  status: 'idle' | 'loading' | 'success' | 'error';
}

export function useAsyncState<T>(
  promiseFn: () => Promise<T>,
  initialValue: T | null = null
) {
  const state = ref<State<T>>({
    data: initialValue,
    error: null,
    status: 'idle',
  });

  const isLoading = computed(() => state.value.status === 'loading');
  const isError = computed(() => state.value.status === 'error');
  const isSuccess = computed(() => state.value.status === 'success');

  async function execute() {
    state.value.status = 'loading';
    state.value.error = null;
    try {
      state.value.data = await promiseFn();
      state.value.status = 'success';
    } catch (e) {
      state.value.error = e as Error;
      state.value.status = 'error';
    }
  }

  function reset() {
    state.value = { data: initialValue, error: null, status: 'idle' };
  }

  return {
    state,
    data: computed(() => state.value.data),
    error: computed(() => state.value.error),
    isLoading,
    isError,
    isSuccess,
    execute,
    reset,
  };
}

Event Bus Pattern

// composables/useEventBus.ts
type Handler<T = void> = (payload: T) => void;

export function useEventBus<T = void>() {
  const handlers = new Set<Handler<T>>();

  function on(handler: Handler<T>) {
    handlers.add(handler);
    return () => handlers.delete(handler); // Return unsubscribe function
  }

  function emit(payload: T) {
    handlers.forEach(handler => handler(payload));
  }

  function once(handler: Handler<T>) {
    const wrapped: Handler<T> = (payload) => {
      handler(payload);
      handlers.delete(wrapped);
    };
    handlers.add(wrapped);
  }

  return { on, emit, once };
}

// Usage
const bus = useEventBus<{ type: string; payload: any }>();
const off = bus.on((event) => console.log(event));
bus.emit({ type: 'user:login', payload: { id: 1 } });
off(); // Unsubscribe

TypeScript Integration

Typed Props and Emits

// Recommended: use type declarations instead of runtime declarations
<script setup lang="ts">
interface Props {
  title: string;
  count?: number;
  items: Array<{ id: number; name: string }>;
  status: 'active' | 'inactive';
}

interface Emits {
  (e: 'update', id: number): void;
  (e: 'delete', id: number): void;
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  status: 'active',
});

const emit = defineEmits<Emits>();

// Generic components
defineOptions({ inheritAttrs: false });
</script>

Typed provide/inject

// types.ts
import type { InjectionKey, Ref } from 'vue';

export interface UserInfo {
  id: number;
  name: string;
  role: 'admin' | 'user';
}

export const UserKey: InjectionKey<Ref<UserInfo>> = Symbol('user');

// Provider.vue
<script setup lang="ts">
import { provide, ref } from 'vue';
import { UserKey } from './types';

const user = ref<UserInfo>({ id: 1, name: 'Alice', role: 'admin' });
provide(UserKey, user);
</script>

// Consumer.vue
<script setup lang="ts">
import { inject } from 'vue';
import { UserKey } from './types';

const user = inject(UserKey); // Type inferred as Ref<UserInfo>
if (!user) throw new Error('UserKey not provided');
</script>

State Management Patterns

Lightweight State: composable + reactive

// stores/useCart.ts
import { reactive, computed } from 'vue';

const state = reactive({
  items: [] as Array<{ id: number; name: string; price: number; qty: number }>,
});

const total = computed(() =>
  state.items.reduce((sum, item) => sum + item.price * item.qty, 0)
);

const itemCount = computed(() =>
  state.items.reduce((sum, item) => sum + item.qty, 0)
);

function addItem(item: { id: number; name: string; price: number }) {
  const existing = state.items.find(i => i.id === item.id);
  if (existing) {
    existing.qty++;
  } else {
    state.items.push({ ...item, qty: 1 });
  }
}

function removeItem(id: number) {
  const index = state.items.findIndex(i => i.id === id);
  if (index > -1) state.items.splice(index, 1);
}

// Export read-only state and action methods
export function useCart() {
  return {
    items: computed(() => state.items),
    total,
    itemCount,
    addItem,
    removeItem,
  };
}

Using Pinia

// stores/user.ts
import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', () => {
  const user = ref<UserInfo | null>(null);
  const token = ref<string | null>(localStorage.getItem('token'));

  const isLoggedIn = computed(() => !!token.value);

  async function login(credentials: LoginParams) {
    const res = await api.login(credentials);
    token.value = res.token;
    user.value = res.user;
    localStorage.setItem('token', res.token);
  }

  function logout() {
    token.value = null;
    user.value = null;
    localStorage.removeItem('token');
  }

  return { user, token, isLoggedIn, login, logout };
});

Migrating from Options API

Progressive Migration Strategy

<!-- Approach 1: setup option (minimal changes) -->
<script>
import { ref } from 'vue';
export default {
  data() {
    return { legacyData: 'old' };
  },
  setup() {
    const newData = ref('new');
    return { newData };
  },
};
</script>

<!-- Approach 2: <script setup> (recommended) -->
<script setup lang="ts">
import { ref } from 'vue';

// Direct definition, no return needed
const count = ref(0);
function increment() {
  count.value++;
}

// Lifecycle
onMounted(() => console.log('mounted'));

// Props and Emits
const props = defineProps<{ title: string }>();
const emit = defineEmits<{ (e: 'change', value: number): void }>();
</script>

Lifecycle Mapping

// Options API           → Composition API
beforeCreate              setup() itself
created                   setup() itself
beforeMount               onBeforeMount
mounted                   onMounted
beforeUpdate              onBeforeUpdate
updated                   onUpdated
beforeUnmount             onBeforeUnmount
unmounted                 onUnmounted
errorCaptured             onErrorCaptured
renderTracked             onRenderTracked
renderTriggered           onRenderTriggered

Watch Migration

// Options API
export default {
  watch: {
    'form.name'(val) {
      this.validateName(val);
    },
    query: {
      handler(val) { this.search(val); },
      immediate: true,
      deep: true,
    },
  },
};

// Composition API
watch(() => form.name, (val) => {
  validateName(val);
});

watch(query, (val) => {
  search(val);
}, { immediate: true, deep: true });

// watchEffect: auto-track dependencies
watchEffect(() => {
  // Automatically tracks all reactive dependencies in formData
  console.log('Form changed:', formData.name, formData.email);
});

Common Pitfalls

1. Using this in setup

// Wrong: no `this` in setup
setup() {
  this.method(); // undefined!
}

// Correct: reference functions directly
setup() {
  function method() { /* ... */ }
  return { method };
}

2. Forgetting ref’s .value

const count = ref(0);

// Wrong: template auto-unwraps, but <script> does not
if (count === 0) { } // Always false

// Correct
if (count.value === 0) { }

3. Losing Reactivity

const state = reactive({ list: [] });

// Wrong: destructuring reactive loses reactivity
const { list } = state;

// Fix 1: toRefs
const { list } = toRefs(state);

// Fix 2: use directly
state.list.push(item);

// Fix 3: use ref instead
const list = ref([]);

4. Proper Use of shallowRef

// shallowRef: only .value replacement triggers updates
const items = shallowRef([{ name: 'A' }]);

items.value[0].name = 'B'; // No update triggered!
items.value = [...items.value]; // Triggers update

// Compare ref + reactive: deep reactivity
const items = ref([{ name: 'A' }]);
items.value[0].name = 'B'; // Triggers update

Conclusion

The Composition API brings better logic reuse and code organization to Vue:

  • Composable functions are the core abstraction — design with care for return types and cleanup logic
  • ref vs reactive: prefer ref; reactive suits complex objects that won’t be replaced
  • TypeScript integration: leverage defineProps<T>(), InjectionKey<T>, and other typing tools
  • Migration strategy: prefer <script setup>, organize new features as composables, migrate old code progressively

The core principle: organize code by feature, not by option type. This significantly improves readability, testability, and reusability.

Comments