⚡ TECH BLOG
Home
Blog
Tags
About
⚡

Powered by Next.js 15 & Modern Web Tech ⚡

Back to Home

Vue 3.5 Composition API: Advanced Patterns and Performance

March 11, 2025
vuetypescriptcomposition-apifrontendjavascript
Vue 3.5 Composition API: Advanced Patterns and Performance

Vue 3.5 Composition API: Advanced Patterns and Performance

When Vue 3.5 came out, I honestly didn't think much of it—after going through the 2.x to 3.x migration, how big could the changes be? But once I started using it, I realized this isn't just minor tweaks. The reactivity system was completely rewritten, performance improved noticeably, and memory usage dropped significantly.

This article explores some advanced patterns with Vue 3.5's Composition API and lessons from real projects.

What Changed in the Reactivity System?

Let's talk about the impact of this rewrite. The previous reactivity system used dependency tracking—each reactive object had its own Dep instance, each component its own Watcher. This worked, but created memory pressure in large applications.

Vue 3.5 takes a different approach:

Version-based Tracking: Instead of maintaining complex dependency graphs, it now uses simple version counters. When data changes, the version increments, and Vue can quickly determine which effects need to re-run.

Double-ended Queue Scheduling: Effect scheduling now uses a more efficient double-ended queue, reducing batched update overhead and ensuring optimal execution order.

Here's a simple example:

import { reactive, effect, computed, watch } from 'vue';

const state = reactive({
  users: [] as Array<{ id: number; name: string }>,
  filter: '',
  page: 1,
});

// Computed caching is better now
const filteredUsers = computed(() => {
  console.log('Computing filtered users...'); // Only logs when dependencies change
  return state.users.filter(user =>
    user.name.toLowerCase().includes(state.filter.toLowerCase())
  );
});

// Watch is more efficient
watch(
  () => state.page,
  async (newPage, oldPage) => {
    console.log(`Page changed from ${oldPage} to ${newPage}`);
    await fetchUsers(newPage);
  },
  { flush: 'sync' } // New flush timing option
);

How Much Did Memory Drop?

Officially about 40% reduction. My real-world tests confirm this. Previously, data-heavy tables like 100k rows could eat up several hundred MB of memory. Now the same scenario has much less memory pressure:

interface DataTableState {
  rows: Array<{
    id: number;
    data: Record<string, unknown>;
    selected: boolean;
    expanded: boolean;
  }>;
  columns: Array<{
    key: string;
    label: string;
    sortable: boolean;
  }>;
  sortColumn: string | null;
  sortDirection: 'asc' | 'desc';
}

// This would be memory-intensive in Vue 3.4
// Vue 3.5 handles it much more easily
const tableState = reactive<DataTableState>({
  rows: [], // Can now handle 100k+ rows comfortably
  columns: [],
  sortColumn: null,
  sortDirection: 'asc',
});

Advanced Composition Patterns

Vue 3.5's improvements enable more sophisticated composition patterns for cleaner code organization.

Service Pattern + Dependency Injection

For complex applications, combining Composition API with DI creates a clean architecture:

// services/UserService.ts
import { ref, computed, readonly } from 'vue';
import type { InjectionKey } from 'vue';

export interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user' | 'guest';
  preferences: UserPreferences;
}

export interface UserPreferences {
  theme: 'light' | 'dark' | 'system';
  language: string;
  notifications: boolean;
}

// Service interface
export interface UserService {
  currentUser: Readonly<Ref<User | null>>;
  isAuthenticated: Readonly<ComputedRef<boolean>>;
  isLoading: Readonly<Ref<boolean>>;
  error: Readonly<Ref<Error | null>>;

  login(email: string, password: string): Promise<void>;
  logout(): Promise<void>;
  updatePreferences(preferences: Partial<UserPreferences>): Promise<void>;
  refresh(): Promise<void>;
}

// Create the service
function createUserService(): UserService {
  const currentUser = ref<User | null>(null);
  const isLoading = ref(false);
  const error = ref<Error | null>(null);

  const isAuthenticated = computed(() => currentUser.value !== null);

  async function login(email: string, password: string): Promise<void> {
    isLoading.value = true;
    error.value = null;

    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });

      if (!response.ok) throw new Error('Login failed');

      currentUser.value = await response.json();
    } catch (e) {
      error.value = e as Error;
      throw e;
    } finally {
      isLoading.value = false;
    }
  }

  async function logout(): Promise<void> {
    await fetch('/api/auth/logout', { method: 'POST' });
    currentUser.value = null;
  }

  async function updatePreferences(
    preferences: Partial<UserPreferences>
  ): Promise<void> {
    if (!currentUser.value) return;

    const response = await fetch('/api/user/preferences', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(preferences),
    });

    if (response.ok) {
      currentUser.value = {
        ...currentUser.value,
        preferences: { ...currentUser.value.preferences, ...preferences },
      };
    }
  }

  async function refresh(): Promise<void> {
    const response = await fetch('/api/auth/me');
    if (response.ok) {
      currentUser.value = await response.json();
    } else {
      currentUser.value = null;
    }
  }

  return {
    currentUser: readonly(currentUser),
    isAuthenticated: readonly(isAuthenticated),
    isLoading: readonly(isLoading),
    error: readonly(error),
    login,
    logout,
    updatePreferences,
    refresh,
  };
}

// Type-safe injection key
export const UserServiceKey: InjectionKey<UserService> = Symbol('UserService');

// Plugin
export const UserServicePlugin = {
  install(app: App) {
    const service = createUserService();
    app.provide(UserServiceKey, service);
  },
};

// Composable for easy component access
export function useUser(): UserService {
  const service = inject(UserServiceKey);
  if (!service) {
    throw new Error('UserService not provided');
  }
  return service;
}

Using it is clean:

// main.ts
app.use(UserServicePlugin);

// In component
const { currentUser, login, logout } = useUser();

Store Pattern: Fine-grained Reactivity

With Vue 3.5's new reactivity system, we can write more efficient stores:

// stores/TaskStore.ts
import { reactive, computed, watchEffect, ref } from 'vue';

interface Task {
  id: string;
  title: string;
  description: string;
  status: 'todo' | 'in_progress' | 'done';
  priority: 'low' | 'medium' | 'high';
  assignee: string | null;
  dueDate: string | null;
  tags: string[];
  createdAt: string;
  updatedAt: string;
}

interface TaskFilters {
  status: Task['status'] | null;
  priority: Task['priority'] | null;
  assignee: string | null;
  search: string;
  tags: string[];
}

export function createTaskStore() {
  // Use Map for O(1) lookups
  const tasks = reactive(new Map<string, Task>());

  const filters = reactive<TaskFilters>({
    status: null,
    priority: null,
    assignee: null,
    search: '',
    tags: [],
  });

  const selectedTaskId = ref<string | null>(null);
  const isLoading = ref(false);

  // Filtering logic
  const filteredTasks = computed(() => {
    const allTasks = Array.from(tasks.values());

    return allTasks.filter(task => {
      if (filters.status && task.status !== filters.status) return false;
      if (filters.priority && task.priority !== filters.priority) return false;
      if (filters.assignee && task.assignee !== filters.assignee) return false;

      if (filters.search) {
        const searchLower = filters.search.toLowerCase();
        if (!task.title.toLowerCase().includes(searchLower) &&
            !task.description.toLowerCase().includes(searchLower)) {
          return false;
        }
      }

      if (filters.tags.length > 0) {
        if (!filters.tags.every(tag => task.tags.includes(tag))) {
          return false;
        }
      }

      return true;
    });
  });

  // Group by status
  const tasksByStatus = computed(() => {
    const grouped: Record<Task['status'], Task[]> = {
      todo: [],
      in_progress: [],
      done: [],
    };

    for (const task of filteredTasks.value) {
      grouped[task.status].push(task);
    }

    return grouped;
  });

  // Fetch data
  async function fetchTasks(): Promise<void> {
    isLoading.value = true;
    try {
      const response = await fetch('/api/tasks');
      const data: Task[] = await response.json();

      tasks.clear();
      data.forEach(task => tasks.set(task.id, task));
    } finally {
      isLoading.value = false;
    }
  }

  // Create task with optimistic update
  async function createTask(
    taskData: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>
  ): Promise<Task> {
    const tempId = `temp-${Date.now()}`;
    const now = new Date().toISOString();

    // Optimistically add it
    const optimisticTask: Task = {
      ...taskData,
      id: tempId,
      createdAt: now,
      updatedAt: now,
    };

    tasks.set(tempId, optimisticTask);

    try {
      const response = await fetch('/api/tasks', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(taskData),
      });

      const realTask: Task = await response.json();

      // Replace with real data
      tasks.delete(tempId);
      tasks.set(realTask.id, realTask);

      return realTask;
    } catch (error) {
      // Rollback on failure
      tasks.delete(tempId);
      throw error;
    }
  }

  // Update task with optimistic update
  async function updateTask(id: string, updates: Partial<Task>): Promise<void> {
    const existingTask = tasks.get(id);
    if (!existingTask) return;

    const optimisticTask = {
      ...existingTask,
      ...updates,
      updatedAt: new Date().toISOString(),
    };

    tasks.set(id, optimisticTask);

    try {
      await fetch(`/api/tasks/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      });
    } catch (error) {
      // Rollback
      tasks.set(id, existingTask);
      throw error;
    }
  }

  // Delete task with optimistic update
  async function deleteTask(id: string): Promise<void> {
    const existingTask = tasks.get(id);
    if (!existingTask) return;

    tasks.delete(id);

    try {
      await fetch(`/api/tasks/${id}`, { method: 'DELETE' });
    } catch (error) {
      tasks.set(id, existingTask);
      throw error;
    }
  }

  function setFilters(newFilters: Partial<TaskFilters>): void {
    Object.assign(filters, newFilters);
  }

  // Auto-save filters to localStorage
  watchEffect(() => {
    localStorage.setItem('task-filters', JSON.stringify(filters));
  });

  // Load saved filters on init
  const savedFilters = localStorage.getItem('task-filters');
  if (savedFilters) {
    Object.assign(filters, JSON.parse(savedFilters));
  }

  return {
    tasks,
    filters,
    selectedTaskId,
    isLoading,
    filteredTasks,
    tasksByStatus,
    fetchTasks,
    createTask,
    updateTask,
    deleteTask,
    setFilters,
  };
}

This store uses Map for fast lookups, optimistic updates for better UX, and auto-persists filters.

Generic Components

Vue 3.5 supports generics in <script setup>, making components much more reusable:

<!-- components/DataTable.vue -->
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed, ref, provide } from 'vue';

interface Props {
  items: T[];
  columns: Array<{
    key: keyof T;
    label: string;
    sortable?: boolean;
    width?: string;
  }>;
  selectable?: boolean;
  expandable?: boolean;
  pageSize?: number;
}

const props = withDefaults(defineProps<Props>(), {
  selectable: false,
  expandable: false,
  pageSize: 10,
});

interface Emits {
  (e: 'select', items: T[]): void;
  (e: 'sort', key: keyof T, direction: 'asc' | 'desc'): void;
  (e: 'row-click', item: T): void;
}

const emit = defineEmits<Emits>();

// Internal state
const sortKey = ref<keyof T | null>(null);
const sortDirection = ref<'asc' | 'desc'>('asc');
const selectedIds = ref(new Set<string>());
const expandedIds = ref(new Set<string>());
const currentPage = ref(1);

// Context for child components
provide('dataTable', {
  sortKey,
  sortDirection,
  selectedIds,
  expandedIds,
  toggleSort: (key: keyof T) => {
    if (sortKey.value === key) {
      sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
    } else {
      sortKey.value = key;
      sortDirection.value = 'asc';
    }
    emit('sort', key, sortDirection.value);
  },
});

// Sorting and pagination
const sortedItems = computed(() => {
  if (!sortKey.value) return props.items;

  return [...props.items].sort((a, b) => {
    const aVal = a[sortKey.value!];
    const bVal = b[sortKey.value!];

    if (aVal === bVal) return 0;

    const comparison = aVal > bVal ? 1 : -1;
    return sortDirection.value === 'asc' ? comparison : -comparison;
  });
});

const paginatedItems = computed(() => {
  const start = (currentPage.value - 1) * props.pageSize;
  return sortedItems.value.slice(start, start + props.pageSize);
});

const totalPages = computed(() =>
  Math.ceil(props.items.length / props.pageSize)
);

// Select all logic
const allSelected = computed({
  get: () => selectedIds.value.size === props.items.length,
  set: (value: boolean) => {
    selectedIds.value.clear();
    if (value) {
      props.items.forEach(item => {
        const id = (item as any).id;
        if (id) selectedIds.value.add(id);
      });
    }
    emit('select', value ? props.items : []);
  },
});

function toggleRowSelection(id: string) {
  if (selectedIds.value.has(id)) {
    selectedIds.value.delete(id);
  } else {
    selectedIds.value.add(id);
  }

  const selectedItems = props.items.filter(
    item => selectedIds.value.has((item as any).id)
  );
  emit('select', selectedItems);
}

function toggleRowExpansion(id: string) {
  if (expandedIds.value.has(id)) {
    expandedIds.value.delete(id);
  } else {
    expandedIds.value.add(id);
  }
}

// Expose methods to parent
defineExpose({
  selectAll: () => { allSelected.value = true; },
  clearSelection: () => { allSelected.value = false; },
  goToPage: (page: number) => { currentPage.value = page; },
});
</script>

<template>
  <div class="data-table">
    <table>
      <thead>
        <tr>
          <th v-if="selectable" class="w-12">
            <input
              type="checkbox"
              v-model="allSelected"
              :indeterminate="selectedIds.size > 0 && selectedIds.size < items.length"
            />
          </th>

          <th
            v-for="column in columns"
            :key="column.key"
            :style="{ width: column.width }"
            :class="{ sortable: column.sortable }"
            @click="column.sortable && toggleSort(column.key)"
          >
            {{ column.label }}
            <span v-if="sortKey === column.key">
              {{ sortDirection === 'asc' ? '↑' : '↓' }}
            </span>
          </th>

          <th v-if="expandable" class="w-12"></th>
        </tr>
      </thead>

      <tbody>
        <template v-for="item in paginatedItems" :key="(item as any).id">
          <tr @click="emit('row-click', item)">
            <td v-if="selectable">
              <input
                type="checkbox"
                :checked="selectedIds.has((item as any).id)"
                @change="toggleRowSelection((item as any).id)"
              />
            </td>

            <td v-for="column in columns" :key="column.key">
              <slot
                :name="`cell-${String(column.key)}`"
                :value="item[column.key]"
                :item="item"
              >
                {{ item[column.key] }}
              </slot>
            </td>

            <td v-if="expandable">
              <button @click="toggleRowExpansion((item as any).id)">
                {{ expandedIds.has((item as any).id) ? '▼' : '▶' }}
              </button>
            </td>
          </tr>

          <tr
            v-if="expandable && expandedIds.has((item as any).id)"
            class="expanded-row"
          >
            <td :colspan="columns.length + (selectable ? 1 : 0) + 1">
              <slot name="expanded" :item="item" />
            </td>
          </tr>
        </template>
      </tbody>
    </table>

    <div v-if="totalPages > 1" class="pagination">
      <button :disabled="currentPage === 1" @click="currentPage--">
        Previous
      </button>

      <span>Page {{ currentPage }} of {{ totalPages }}</span>

      <button :disabled="currentPage === totalPages" @click="currentPage++">
        Next
      </button>
    </div>
  </div>
</template>

Usage:

<DataTable
  :items="users"
  :columns="[
    { key: 'name', label: 'Name', sortable: true },
    { key: 'email', label: 'Email' },
    { key: 'role', label: 'Role', sortable: true },
  ]"
  selectable
  @select="onSelect"
>
  <template #cell-name="{ value, item }">
    <strong>{{ value }}</strong>
  </template>
</DataTable>

Performance Optimization Tips

Vue 3.5 provides new tools for performance optimization.

Suspense + Async Components

<!-- App.vue -->
<script setup>
import { defineAsyncComponent } from 'vue';

const HeavyChart = defineAsyncComponent(() =>
  import('./components/HeavyChart.vue')
);

const DataGrid = defineAsyncComponent({
  loader: () => import('./components/DataGrid.vue'),
  loadingComponent: LoadingSpinner,
  delay: 200,
  timeout: 10000,
});
</script>

<template>
  <Suspense>
    <template #default>
      <div class="dashboard">
        <HeavyChart />
        <DataGrid />
      </div>
    </template>

    <template #fallback>
      <LoadingSkeleton />
    </template>
  </Suspense>
</template>

Shallow Reactivity for Large Data

For large data that doesn't need deep reactivity, use shallowReactive or shallowRef:

import { shallowReactive, shallowRef, triggerRef } from 'vue';

// Large array, don't need each item reactive
const largeDataset = shallowReactive<Array<DataPoint>>([]);

function updateDataset(newData: DataPoint[]) {
  largeDataset.length = 0;
  largeDataset.push(...newData);
}

// Object with stable structure
const config = shallowRef<Config>({
  apiUrl: '',
  timeout: 5000,
  features: {},
});

// When you need to update
config.value = { ...config.value, timeout: 10000 };

This significantly reduces reactivity overhead, especially for table data and chart data scenarios.

Final Thoughts

Vue 3.5's Composition API combined with the rewritten reactivity system opens up more possibilities for frontend development. Many performance issues that previously required careful optimization are now naturally resolved.

The patterns shared here—service injection, fine-grained stores, generic components—are ones I've used in real projects. They genuinely make code cleaner and more maintainable.

One reminder though: don't use patterns just for the sake of patterns. Vue's philosophy has always been "progressive"—choose the right level of abstraction for your project's complexity. Simple pages can use setup functions directly; complex applications can consider service layers and store layers.

If this article helped you, feel free to discuss in the comments. Any questions, just ask me directly.

Share:

💬 Comments