Vue.js 3 dan Composition API: Panduan Lengkap untuk Developer Modern

Pelajari Vue.js 3 dan Composition API dari dasar hingga advanced, termasuk reactive system, composables, dan best practices untuk aplikasi scalable.

25 Februari 2024
Novin Ardian Yulianto
Vue.jsComposition APIJavaScriptFrontendReactive Programming
Vue.js 3 dan Composition API: Panduan Lengkap untuk Developer Modern

Vue.js 3 dan Composition API: Panduan Lengkap untuk Developer Modern

Vue.js 3 membawa perubahan revolusioner dengan Composition API yang memberikan cara baru untuk mengorganisir dan menggunakan kembali logic dalam komponen Vue. Artikel ini akan membahas secara mendalam Vue.js 3 dan Composition API.

Apa yang Baru di Vue.js 3?

Vue.js 3 hadir dengan berbagai peningkatan signifikan yang membuatnya lebih powerful dan performant.

Fitur Utama Vue.js 3

  1. Composition API: Cara baru untuk mengorganisir component logic
  2. Multiple Root Elements: Tidak lagi terbatas pada single root element
  3. Teleport: Render komponen di lokasi DOM yang berbeda
  4. Suspense: Built-in support untuk async components
  5. Performance Improvements: Faster rendering dan smaller bundle size
  6. Better TypeScript Support: First-class TypeScript integration
  7. Tree-shaking Support: Better bundle optimization

Vue.js 2 vs Vue.js 3

| Feature | Vue.js 2 | Vue.js 3 | | ------------- | ----------- | ------------------------- | | API Style | Options API | Options + Composition API | | Root Elements | Single | Multiple | | Bundle Size | ~34KB | ~10KB (tree-shaken) | | TypeScript | Add-on | Built-in | | Performance | Good | 2x faster | | IE Support | IE9+ | IE11+ |

Composition API Fundamentals

1. Setup Function

Setup function adalah entry point untuk menggunakan Composition API.

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
    <button @click="decrement">Decrement</button>
  </div>
</template>

<script>
import { ref, computed, onMounted, onUnmounted } from "vue";

export default {
  name: "Counter",

  // Props definition
  props: {
    initialCount: {
      type: Number,
      default: 0,
    },
  },

  // Emits definition
  emits: ["countChanged"],

  // Setup function
  setup(props, { emit, slots, attrs, expose }) {
    // Reactive state
    const count = ref(props.initialCount);
    const title = ref("My Counter App");

    // Computed properties
    const doubleCount = computed(() => count.value * 2);
    const isEven = computed(() => count.value % 2 === 0);

    // Methods
    const increment = () => {
      count.value++;
      emit("countChanged", count.value);
    };

    const decrement = () => {
      count.value--;
      emit("countChanged", count.value);
    };

    const reset = () => {
      count.value = props.initialCount;
    };

    // Lifecycle hooks
    onMounted(() => {
      console.log("Component mounted");
    });

    onUnmounted(() => {
      console.log("Component unmounted");
    });

    // Expose methods to parent (optional)
    expose({
      reset,
    });

    // Return reactive state and methods
    return {
      title,
      count,
      doubleCount,
      isEven,
      increment,
      decrement,
    };
  },
};
</script>

2. Script Setup (Syntactic Sugar)

<template>
  <div>
    <h1>{{ title }}</h1>
    <p>Count: {{ count }}</p>
    <p>Double: {{ doubleCount }}</p>
    <button @click="increment">+</button>
    <button @click="decrement">-</button>

    <!-- Child component -->
    <UserProfile :user="currentUser" @user-updated="handleUserUpdate" />
  </div>
</template>

<script setup>
import {
  ref,
  computed,
  onMounted,
  defineProps,
  defineEmits,
  defineExpose,
} from "vue";
import UserProfile from "./UserProfile.vue";

// Props
const props = defineProps({
  initialCount: {
    type: Number,
    default: 0,
  },
  user: {
    type: Object,
    required: true,
  },
});

// Emits
const emit = defineEmits(["countChanged", "userUpdated"]);

// Reactive state
const count = ref(props.initialCount);
const title = ref("Counter with Script Setup");
const currentUser = ref(props.user);

// Computed
const doubleCount = computed(() => count.value * 2);

// Methods
const increment = () => {
  count.value++;
  emit("countChanged", count.value);
};

const decrement = () => {
  count.value--;
  emit("countChanged", count.value);
};

const reset = () => {
  count.value = props.initialCount;
};

const handleUserUpdate = (updatedUser) => {
  currentUser.value = updatedUser;
  emit("userUpdated", updatedUser);
};

// Lifecycle
onMounted(() => {
  console.log("Component mounted with script setup");
});

// Expose to parent
defineExpose({
  reset,
  getCurrentCount: () => count.value,
});
</script>

Reactive System

1. ref() dan reactive()

import { ref, reactive, toRefs, isRef, unref } from "vue";

// ref() untuk primitive values
const count = ref(0);
const message = ref("Hello");
const isLoading = ref(false);

// Accessing ref values
console.log(count.value); // 0
count.value = 10;

// reactive() untuk objects
const state = reactive({
  user: {
    name: "John",
    email: "john@example.com",
    preferences: {
      theme: "dark",
      language: "en",
    },
  },
  posts: [],
  loading: false,
});

// Direct property access (no .value needed)
state.user.name = "Jane";
state.posts.push({ id: 1, title: "New Post" });

// toRefs() untuk destructuring reactive objects
const { user, posts, loading } = toRefs(state);

// Now you can use them as refs
user.value.name = "Bob";
posts.value.push({ id: 2, title: "Another Post" });

// Utility functions
const someValue = ref(42);
console.log(isRef(someValue)); // true
console.log(unref(someValue)); // 42 (same as someValue.value)

2. Computed Properties

import { ref, computed, watch } from "vue";

const firstName = ref("John");
const lastName = ref("Doe");

// Basic computed
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});

// Writable computed
const fullNameWritable = computed({
  get() {
    return `${firstName.value} ${lastName.value}`;
  },
  set(newValue) {
    const names = newValue.split(" ");
    firstName.value = names[0];
    lastName.value = names[names.length - 1];
  },
});

// Complex computed with dependencies
const users = ref([
  { id: 1, name: "John", active: true, role: "admin" },
  { id: 2, name: "Jane", active: false, role: "user" },
  { id: 3, name: "Bob", active: true, role: "user" },
]);

const activeUsers = computed(() => {
  return users.value.filter((user) => user.active);
});

const adminUsers = computed(() => {
  return users.value.filter((user) => user.role === "admin");
});

const userStats = computed(() => {
  const total = users.value.length;
  const active = activeUsers.value.length;
  const admins = adminUsers.value.length;

  return {
    total,
    active,
    inactive: total - active,
    admins,
    users: total - admins,
  };
});

3. Watchers

import { ref, reactive, watch, watchEffect, nextTick } from "vue";

const count = ref(0);
const state = reactive({ name: "John", age: 30 });

// Basic watcher
const stopWatcher = watch(count, (newValue, oldValue) => {
  console.log(`Count changed from ${oldValue} to ${newValue}`);
});

// Watch multiple sources
watch([count, () => state.name], ([newCount, newName], [oldCount, oldName]) => {
  console.log(`Count: ${oldCount} -> ${newCount}`);
  console.log(`Name: ${oldName} -> ${newName}`);
});

// Watch reactive object
watch(
  () => state,
  (newState, oldState) => {
    console.log("State changed:", newState);
  },
  { deep: true } // Deep watching for nested properties
);

// Watch specific property
watch(
  () => state.name,
  (newName, oldName) => {
    console.log(`Name changed: ${oldName} -> ${newName}`);
  }
);

// Immediate watcher
watch(
  count,
  (newValue) => {
    console.log("Count is now:", newValue);
  },
  { immediate: true } // Runs immediately with current value
);

// watchEffect - automatically tracks dependencies
const stopEffect = watchEffect(() => {
  console.log(`Count is ${count.value} and name is ${state.name}`);
  // Automatically re-runs when count or state.name changes
});

// Async watcher
watch(
  () => state.name,
  async (newName) => {
    const userData = await fetchUserData(newName);
    // Handle async operation
  }
);

// Stop watchers
stopWatcher();
stopEffect();

// Flush timing
watch(
  count,
  () => {
    // Runs after component updates
  },
  { flush: "post" }
);

watch(
  count,
  () => {
    // Runs before component updates
  },
  { flush: "pre" }
);

watch(
  count,
  () => {
    // Runs synchronously
  },
  { flush: "sync" }
);

Composables (Custom Hooks)

1. Basic Composable

// composables/useCounter.js
import { ref, computed } from "vue";

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const increment = () => count.value++;
  const decrement = () => count.value--;
  const reset = () => (count.value = initialValue);

  const isEven = computed(() => count.value % 2 === 0);
  const isPositive = computed(() => count.value > 0);

  return {
    count,
    increment,
    decrement,
    reset,
    isEven,
    isPositive,
  };
}

2. Advanced Composables

// composables/useApi.js
import { ref, reactive } from "vue";

export function useApi(baseURL = "/api") {
  const loading = ref(false);
  const error = ref(null);

  const request = async (endpoint, options = {}) => {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(`${baseURL}${endpoint}`, {
        headers: {
          "Content-Type": "application/json",
          ...options.headers,
        },
        ...options,
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();
      return data;
    } catch (err) {
      error.value = err.message;
      throw err;
    } finally {
      loading.value = false;
    }
  };

  const get = (endpoint) => request(endpoint);
  const post = (endpoint, data) =>
    request(endpoint, {
      method: "POST",
      body: JSON.stringify(data),
    });
  const put = (endpoint, data) =>
    request(endpoint, {
      method: "PUT",
      body: JSON.stringify(data),
    });
  const del = (endpoint) => request(endpoint, { method: "DELETE" });

  return {
    loading,
    error,
    request,
    get,
    post,
    put,
    delete: del,
  };
}

// composables/useLocalStorage.js
import { ref, watch } from "vue";

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key);
  const initialValue = storedValue ? JSON.parse(storedValue) : defaultValue;

  const value = ref(initialValue);

  watch(
    value,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue));
    },
    { deep: true }
  );

  return value;
}

// composables/useDebounce.js
import { ref, watch } from "vue";

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value);

  watch(value, (newValue) => {
    const timeout = setTimeout(() => {
      debouncedValue.value = newValue;
    }, delay);

    return () => clearTimeout(timeout);
  });

  return debouncedValue;
}

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

export function useIntersectionObserver(options = {}) {
  const target = ref(null);
  const isIntersecting = ref(false);
  const observer = ref(null);

  onMounted(() => {
    observer.value = new IntersectionObserver(([entry]) => {
      isIntersecting.value = entry.isIntersecting;
    }, options);

    if (target.value) {
      observer.value.observe(target.value);
    }
  });

  onUnmounted(() => {
    if (observer.value) {
      observer.value.disconnect();
    }
  });

  return {
    target,
    isIntersecting,
  };
}

3. Using Composables in Components

<template>
  <div>
    <!-- Counter -->
    <div class="counter">
      <h2>Counter: {{ count }}</h2>
      <p>Is Even: {{ isEven }}</p>
      <p>Is Positive: {{ isPositive }}</p>
      <button @click="increment">+</button>
      <button @click="decrement">-</button>
      <button @click="reset">Reset</button>
    </div>

    <!-- API Data -->
    <div class="api-data">
      <h2>Users</h2>
      <div v-if="loading">Loading...</div>
      <div v-if="error" class="error">{{ error }}</div>
      <ul v-if="users.length">
        <li v-for="user in users" :key="user.id">
          {{ user.name }} - {{ user.email }}
        </li>
      </ul>
      <button @click="fetchUsers">Fetch Users</button>
    </div>

    <!-- Local Storage -->
    <div class="settings">
      <h2>Settings</h2>
      <label>
        Theme:
        <select v-model="settings.theme">
          <option value="light">Light</option>
          <option value="dark">Dark</option>
        </select>
      </label>
      <label>
        Language:
        <select v-model="settings.language">
          <option value="en">English</option>
          <option value="es">Spanish</option>
          <option value="fr">French</option>
        </select>
      </label>
    </div>

    <!-- Intersection Observer -->
    <div class="scroll-section">
      <div ref="target" class="target-element">
        <p v-if="isIntersecting">Element is visible!</p>
        <p v-else>Element is not visible</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import { useCounter } from "@/composables/useCounter";
import { useApi } from "@/composables/useApi";
import { useLocalStorage } from "@/composables/useLocalStorage";
import { useIntersectionObserver } from "@/composables/useIntersectionObserver";

// Use counter composable
const { count, increment, decrement, reset, isEven, isPositive } =
  useCounter(10);

// Use API composable
const { loading, error, get } = useApi();
const users = ref([]);

const fetchUsers = async () => {
  try {
    const data = await get("/users");
    users.value = data;
  } catch (err) {
    console.error("Failed to fetch users:", err);
  }
};

// Use local storage composable
const settings = useLocalStorage("app-settings", {
  theme: "light",
  language: "en",
});

// Use intersection observer composable
const { target, isIntersecting } = useIntersectionObserver({
  threshold: 0.5,
});

// Fetch users on mount
onMounted(() => {
  fetchUsers();
});
</script>

Lifecycle Hooks

import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onErrorCaptured,
  onActivated,
  onDeactivated,
} from "vue";

export default {
  setup() {
    // Before component is mounted
    onBeforeMount(() => {
      console.log("Before mount");
    });

    // After component is mounted
    onMounted(() => {
      console.log("Mounted");
      // DOM is available
      // Good place for API calls, event listeners
    });

    // Before component updates
    onBeforeUpdate(() => {
      console.log("Before update");
    });

    // After component updates
    onUpdated(() => {
      console.log("Updated");
      // DOM has been updated
    });

    // Before component is unmounted
    onBeforeUnmount(() => {
      console.log("Before unmount");
      // Cleanup: remove event listeners, cancel timers
    });

    // After component is unmounted
    onUnmounted(() => {
      console.log("Unmounted");
    });

    // Error handling
    onErrorCaptured((error, instance, info) => {
      console.error("Error captured:", error, info);
      // Return false to prevent error from propagating
      return false;
    });

    // Keep-alive hooks
    onActivated(() => {
      console.log("Component activated");
    });

    onDeactivated(() => {
      console.log("Component deactivated");
    });
  },
};

Advanced Features

1. Teleport

<template>
  <div>
    <h1>My App</h1>

    <!-- Modal will be rendered in body -->
    <Teleport to="body">
      <div v-if="showModal" class="modal">
        <div class="modal-content">
          <h2>Modal Title</h2>
          <p>Modal content goes here</p>
          <button @click="showModal = false">Close</button>
        </div>
      </div>
    </Teleport>

    <!-- Notification will be rendered in #notifications -->
    <Teleport to="#notifications">
      <div
        v-for="notification in notifications"
        :key="notification.id"
        class="notification"
      >
        {{ notification.message }}
      </div>
    </Teleport>

    <button @click="showModal = true">Show Modal</button>
  </div>
</template>

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

const showModal = ref(false);
const notifications = ref([
  { id: 1, message: "Welcome!" },
  { id: 2, message: "New message received" },
]);
</script>

<style>
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  justify-content: center;
  align-items: center;
}

.modal-content {
  background: white;
  padding: 2rem;
  border-radius: 8px;
}
</style>

2. Suspense

<template>
  <div>
    <h1>Async Components Demo</h1>

    <Suspense>
      <!-- Async component -->
      <template #default>
        <AsyncUserProfile :user-id="userId" />
      </template>

      <!-- Loading fallback -->
      <template #fallback>
        <div class="loading">
          <p>Loading user profile...</p>
          <div class="spinner"></div>
        </div>
      </template>
    </Suspense>

    <!-- Nested Suspense -->
    <Suspense>
      <template #default>
        <div>
          <AsyncUserPosts :user-id="userId" />
          <AsyncUserComments :user-id="userId" />
        </div>
      </template>

      <template #fallback>
        <div>Loading user data...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import { ref } from "vue";
import AsyncUserProfile from "./AsyncUserProfile.vue";
import AsyncUserPosts from "./AsyncUserPosts.vue";
import AsyncUserComments from "./AsyncUserComments.vue";

const userId = ref(1);
</script>
<!-- AsyncUserProfile.vue -->
<template>
  <div class="user-profile">
    <img :src="user.avatar" :alt="user.name" />
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p>{{ user.bio }}</p>
  </div>
</template>

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

const props = defineProps({
  userId: {
    type: Number,
    required: true,
  },
});

// Async setup
const user = ref(null);

// This will be awaited by Suspense
const fetchUser = async () => {
  const response = await fetch(`/api/users/${props.userId}`);
  return response.json();
};

// Await the async operation
user.value = await fetchUser();
</script>

3. Provide/Inject

<!-- Parent Component -->
<template>
  <div>
    <h1>Theme Provider</h1>
    <ThemeToggle />
    <UserDashboard />
  </div>
</template>

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

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

// User state
const user = ref({
  id: 1,
  name: "John Doe",
  email: "john@example.com",
});

const updateUser = (updates) => {
  user.value = { ...user.value, ...updates };
};

// Provide to all descendants
provide("theme", {
  theme: readonly(theme),
  toggleTheme,
});

provide("user", {
  user: readonly(user),
  updateUser,
});

// Provide API client
provide("api", {
  get: (url) => fetch(url).then((r) => r.json()),
  post: (url, data) =>
    fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    }).then((r) => r.json()),
});
</script>
<!-- Child Component -->
<template>
  <div :class="`theme-${theme}`">
    <h2>User Dashboard</h2>
    <p>Welcome, {{ user.name }}!</p>
    <button @click="handleThemeToggle">Toggle Theme</button>
    <button @click="updateProfile">Update Profile</button>
  </div>
</template>

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

// Inject provided values
const { theme, toggleTheme } = inject("theme");
const { user, updateUser } = inject("user");
const api = inject("api");

const handleThemeToggle = () => {
  toggleTheme();
};

const updateProfile = async () => {
  try {
    const updatedUser = await api.post("/api/users/profile", {
      name: "John Smith",
    });
    updateUser(updatedUser);
  } catch (error) {
    console.error("Failed to update profile:", error);
  }
};
</script>

<style scoped>
.theme-light {
  background: white;
  color: black;
}

.theme-dark {
  background: #1a1a1a;
  color: white;
}
</style>

State Management dengan Pinia

1. Store Setup

// stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useUserStore = defineStore("user", () => {
  // State
  const user = ref(null);
  const loading = ref(false);
  const error = ref(null);

  // Getters
  const isAuthenticated = computed(() => !!user.value);
  const userInitials = computed(() => {
    if (!user.value) return "";
    const names = user.value.name.split(" ");
    return names
      .map((name) => name[0])
      .join("")
      .toUpperCase();
  });

  // Actions
  const login = async (credentials) => {
    loading.value = true;
    error.value = null;

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

      if (!response.ok) {
        throw new Error("Login failed");
      }

      const userData = await response.json();
      user.value = userData.user;
      localStorage.setItem("token", userData.token);

      return userData;
    } catch (err) {
      error.value = err.message;
      throw err;
    } finally {
      loading.value = false;
    }
  };

  const logout = () => {
    user.value = null;
    localStorage.removeItem("token");
  };

  const updateProfile = async (updates) => {
    loading.value = true;

    try {
      const response = await fetch("/api/user/profile", {
        method: "PUT",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${localStorage.getItem("token")}`,
        },
        body: JSON.stringify(updates),
      });

      const updatedUser = await response.json();
      user.value = { ...user.value, ...updatedUser };

      return updatedUser;
    } catch (err) {
      error.value = err.message;
      throw err;
    } finally {
      loading.value = false;
    }
  };

  return {
    // State
    user,
    loading,
    error,

    // Getters
    isAuthenticated,
    userInitials,

    // Actions
    login,
    logout,
    updateProfile,
  };
});

// stores/posts.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { useUserStore } from "./user";

export const usePostsStore = defineStore("posts", () => {
  const userStore = useUserStore();

  // State
  const posts = ref([]);
  const loading = ref(false);
  const currentPage = ref(1);
  const totalPages = ref(0);

  // Getters
  const publishedPosts = computed(() =>
    posts.value.filter((post) => post.published)
  );

  const userPosts = computed(() =>
    posts.value.filter((post) => post.authorId === userStore.user?.id)
  );

  // Actions
  const fetchPosts = async (page = 1) => {
    loading.value = true;

    try {
      const response = await fetch(`/api/posts?page=${page}`);
      const data = await response.json();

      posts.value = data.posts;
      currentPage.value = data.currentPage;
      totalPages.value = data.totalPages;
    } catch (error) {
      console.error("Failed to fetch posts:", error);
    } finally {
      loading.value = false;
    }
  };

  const createPost = async (postData) => {
    const response = await fetch("/api/posts", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${localStorage.getItem("token")}`,
      },
      body: JSON.stringify(postData),
    });

    const newPost = await response.json();
    posts.value.unshift(newPost);

    return newPost;
  };

  return {
    posts,
    loading,
    currentPage,
    totalPages,
    publishedPosts,
    userPosts,
    fetchPosts,
    createPost,
  };
});

2. Using Stores in Components

<template>
  <div>
    <!-- User Info -->
    <div v-if="userStore.isAuthenticated" class="user-info">
      <div class="avatar">{{ userStore.userInitials }}</div>
      <span>{{ userStore.user.name }}</span>
      <button @click="handleLogout">Logout</button>
    </div>

    <!-- Login Form -->
    <form v-else @submit.prevent="handleLogin" class="login-form">
      <input v-model="email" type="email" placeholder="Email" required />
      <input
        v-model="password"
        type="password"
        placeholder="Password"
        required
      />
      <button type="submit" :disabled="userStore.loading">Login</button>
    </form>

    <!-- Posts -->
    <div class="posts">
      <h2>Posts</h2>
      <div v-if="postsStore.loading">Loading posts...</div>
      <div v-else>
        <article
          v-for="post in postsStore.publishedPosts"
          :key="post.id"
          class="post"
        >
          <h3>{{ post.title }}</h3>
          <p>{{ post.excerpt }}</p>
          <small>By {{ post.author.name }}</small>
        </article>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from "vue";
import { useUserStore } from "@/stores/user";
import { usePostsStore } from "@/stores/posts";

const userStore = useUserStore();
const postsStore = usePostsStore();

// Login form
const email = ref("");
const password = ref("");

const handleLogin = async () => {
  try {
    await userStore.login({
      email: email.value,
      password: password.value,
    });

    // Reset form
    email.value = "";
    password.value = "";
  } catch (error) {
    alert("Login failed: " + error.message);
  }
};

const handleLogout = () => {
  userStore.logout();
};

// Fetch posts on mount
onMounted(() => {
  postsStore.fetchPosts();
});
</script>

Testing Vue.js 3 Components

1. Unit Testing dengan Vitest

// tests/components/Counter.test.js
import { mount } from "@vue/test-utils";
import { describe, it, expect } from "vitest";
import Counter from "@/components/Counter.vue";

describe("Counter", () => {
  it("renders initial count", () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 5 },
    });

    expect(wrapper.text()).toContain("Count: 5");
  });

  it("increments count when button is clicked", async () => {
    const wrapper = mount(Counter);

    await wrapper.find('[data-testid="increment"]').trigger("click");

    expect(wrapper.text()).toContain("Count: 1");
  });

  it("emits countChanged event", async () => {
    const wrapper = mount(Counter);

    await wrapper.find('[data-testid="increment"]').trigger("click");

    expect(wrapper.emitted("countChanged")).toBeTruthy();
    expect(wrapper.emitted("countChanged")[0]).toEqual([1]);
  });

  it("resets count when reset method is called", async () => {
    const wrapper = mount(Counter, {
      props: { initialCount: 10 },
    });

    // Increment first
    await wrapper.find('[data-testid="increment"]').trigger("click");
    expect(wrapper.text()).toContain("Count: 11");

    // Reset
    await wrapper.vm.reset();
    expect(wrapper.text()).toContain("Count: 10");
  });
});

2. Testing Composables

// tests/composables/useCounter.test.js
import { describe, it, expect } from "vitest";
import { useCounter } from "@/composables/useCounter";

describe("useCounter", () => {
  it("initializes with default value", () => {
    const { count } = useCounter();
    expect(count.value).toBe(0);
  });

  it("initializes with custom value", () => {
    const { count } = useCounter(10);
    expect(count.value).toBe(10);
  });

  it("increments count", () => {
    const { count, increment } = useCounter(5);

    increment();
    expect(count.value).toBe(6);
  });

  it("decrements count", () => {
    const { count, decrement } = useCounter(5);

    decrement();
    expect(count.value).toBe(4);
  });

  it("resets to initial value", () => {
    const { count, increment, reset } = useCounter(10);

    increment();
    increment();
    expect(count.value).toBe(12);

    reset();
    expect(count.value).toBe(10);
  });

  it("computes isEven correctly", () => {
    const { count, increment, isEven } = useCounter(2);

    expect(isEven.value).toBe(true);

    increment();
    expect(isEven.value).toBe(false);
  });
});

Performance Optimization

1. Component Optimization

<template>
  <div>
    <!-- Use v-memo for expensive lists -->
    <div
      v-for="item in expensiveList"
      :key="item.id"
      v-memo="[item.id, item.name, item.status]"
    >
      <ExpensiveComponent :item="item" />
    </div>

    <!-- Use v-once for static content -->
    <div v-once>
      <h1>{{ title }}</h1>
      <p>{{ description }}</p>
    </div>

    <!-- Lazy load components -->
    <Suspense>
      <LazyComponent v-if="shouldLoadComponent" />
    </Suspense>
  </div>
</template>

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

// Lazy load component
const LazyComponent = defineAsyncComponent(() => import("./LazyComponent.vue"));

const items = ref([]);
const filter = ref("");

// Memoize expensive computations
const expensiveList = computed(() => {
  return items.value
    .filter((item) => item.name.includes(filter.value))
    .map((item) => ({
      ...item,
      computedValue: expensiveCalculation(item),
    }));
});

function expensiveCalculation(item) {
  // Expensive operation
  return item.value * Math.random();
}
</script>

2. Reactive Performance

// Use shallowRef for large objects that don't need deep reactivity
import { shallowRef, triggerRef } from "vue";

const largeObject = shallowRef({
  data: new Array(10000).fill(0).map((_, i) => ({ id: i, value: i })),
});

// Manually trigger reactivity when needed
const updateLargeObject = () => {
  largeObject.value.data.push({ id: Date.now(), value: Math.random() });
  triggerRef(largeObject); // Manually trigger update
};

// Use shallowReactive for objects with many properties
import { shallowReactive } from "vue";

const state = shallowReactive({
  user: {
    /* large user object */
  },
  settings: {
    /* large settings object */
  },
  cache: {
    /* large cache object */
  },
});

// Use markRaw for non-reactive data
import { markRaw } from "vue";

const nonReactiveData = markRaw({
  heavyLibrary: new SomeHeavyLibrary(),
  constants: {
    /* large constants object */
  },
});

Best Practices

1. Component Organization

// Good: Organize by feature
// composables/useUserManagement.js
export function useUserManagement() {
  const users = ref([]);
  const loading = ref(false);
  const error = ref(null);

  const fetchUsers = async () => {
    /* ... */
  };
  const createUser = async (userData) => {
    /* ... */
  };
  const updateUser = async (id, updates) => {
    /* ... */
  };
  const deleteUser = async (id) => {
    /* ... */
  };

  return {
    // State
    users,
    loading,
    error,

    // Actions
    fetchUsers,
    createUser,
    updateUser,
    deleteUser,
  };
}

// Good: Single responsibility
// composables/useValidation.js
export function useValidation() {
  const validateEmail = (email) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  };

  const validatePassword = (password) => {
    return (
      password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password)
    );
  };

  return {
    validateEmail,
    validatePassword,
  };
}

2. Error Handling

// composables/useErrorHandler.js
import { ref } from "vue";

export function useErrorHandler() {
  const error = ref(null);
  const isError = computed(() => !!error.value);

  const handleError = (err) => {
    console.error("Error occurred:", err);
    error.value = err.message || "An unexpected error occurred";

    // Send to error tracking service
    if (process.env.NODE_ENV === "production") {
      // Sentry.captureException(err)
    }
  };

  const clearError = () => {
    error.value = null;
  };

  return {
    error,
    isError,
    handleError,
    clearError,
  };
}

// Usage in component
const { error, handleError, clearError } = useErrorHandler();

const fetchData = async () => {
  try {
    const data = await api.get("/data");
    return data;
  } catch (err) {
    handleError(err);
  }
};

3. TypeScript Integration

// types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  createdAt: Date;
}

export interface CreateUserInput {
  name: string;
  email: string;
  password: string;
}

// composables/useUsers.ts
import { ref, computed, type Ref } from "vue";
import type { User, CreateUserInput } from "@/types/user";

export function useUsers() {
  const users: Ref<User[]> = ref([]);
  const loading = ref(false);
  const error: Ref<string | null> = ref(null);

  const activeUsers = computed(() => users.value.filter((user) => user.active));

  const fetchUsers = async (): Promise<void> => {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch("/api/users");
      const data: User[] = await response.json();
      users.value = data;
    } catch (err) {
      error.value = err instanceof Error ? err.message : "Unknown error";
    } finally {
      loading.value = false;
    }
  };

  const createUser = async (input: CreateUserInput): Promise<User | null> => {
    try {
      const response = await fetch("/api/users", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(input),
      });

      const newUser: User = await response.json();
      users.value.push(newUser);

      return newUser;
    } catch (err) {
      error.value =
        err instanceof Error ? err.message : "Failed to create user";
      return null;
    }
  };

  return {
    users,
    loading,
    error,
    activeUsers,
    fetchUsers,
    createUser,
  };
}

Kesimpulan

Vue.js 3 dengan Composition API memberikan cara yang lebih powerful dan flexible untuk membangun aplikasi frontend modern. Dengan memahami konsep-konsep fundamental dan menerapkan best practices, Anda dapat:

  1. Meningkatkan reusability dengan composables
  2. Memperbaiki organization dengan logical grouping
  3. Meningkatkan performance dengan optimizations
  4. Memperkuat type safety dengan TypeScript
  5. Memudahkan testing dengan better separation of concerns
  6. Meningkatkan maintainability dengan clear patterns

Composition API bukan pengganti Options API, tetapi alternatif yang memberikan lebih banyak fleksibilitas untuk aplikasi yang kompleks. Mulailah dengan use cases sederhana dan secara bertahap adopsi advanced features sesuai kebutuhan project Anda.

Mari Mulai Kolaborasi.

Ide digital Anda siap menjadi produk nyata. Konsultasikan gratis dengan tim kami dan wujudkan solusi yang tepat untuk bisnis Anda.

Kami menggunakan cookie
Kami menggunakan cookie untuk memastikan Anda mendapatkan pengalaman terbaik di website kami. Untuk informasi lebih lanjut tentang penggunaan cookie, silakan lihat kebijakan cookie kami.

Dengan mengklik "Terima", Anda menyetujui penggunaan cookie kami.

Pelajari lebih lanjut