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
- Composition API: Cara baru untuk mengorganisir component logic
- Multiple Root Elements: Tidak lagi terbatas pada single root element
- Teleport: Render komponen di lokasi DOM yang berbeda
- Suspense: Built-in support untuk async components
- Performance Improvements: Faster rendering dan smaller bundle size
- Better TypeScript Support: First-class TypeScript integration
- 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:
- Meningkatkan reusability dengan composables
- Memperbaiki organization dengan logical grouping
- Meningkatkan performance dengan optimizations
- Memperkuat type safety dengan TypeScript
- Memudahkan testing dengan better separation of concerns
- 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.



