Vue 3 #
Vue 3 adalah framework progresif yang menggabungkan reactivity granular, Composition API, dan tooling modern (Vite). Untuk aplikasi skala kecil-menengah, Vue 3 + Vite + Pinia adalah stack yang ergonomis dan ringan. Untuk local development, container Docker memastikan setiap developer punya Node version sama, dependency native terisolasi, dan onboarding instan.
Artikel ini membahas setup Vue 3 dengan Docker Compose untuk local development, termasuk Vite + HMR, Composition API, state management dengan Pinia, Vue Router, dan production image dengan nginx.
Prasyarat #
Pastikan sudah terinstall:
- Docker dan Docker Compose versi terbaru
- Node.js 20+ (opsional, untuk tooling)
Project Vue 3 standar (bootstrap dengan npm create vue@latest):
my-vue-app/
├── src/
│ ├── main.ts
│ ├── App.vue
│ ├── components/
│ ├── composables/
│ ├── views/
│ ├── stores/
│ ├── router/
│ └── assets/
├── public/
├── index.html
├── package.json
├── package-lock.json
├── vite.config.ts
├── tsconfig.json
├── Dockerfile
└── docker-compose.yml
Mengapa Vite untuk Vue 3 #
Vite adalah bundler resmi Vue 3 (sejak Vue 3.0). Create Vue App (Vue CLI) sudah deprecated. Vite memberikan:
- Dev server super cepat — startup < 500ms
- HMR instan — perubahan CSS/JS update tanpa refresh
- Build optimal — Rollup menghasilkan bundle kecil
- TypeScript first — konfigurasi TypeScript built-in
Dockerfile Multi-Stage #
Vue 3 + Vite menghasilkan static build (HTML + JS + CSS). Image production cukup nginx.
# syntax=docker/dockerfile:1.6
# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ---- Stage 2: Build Vue ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build
# ---- Stage 3: Production dengan nginx ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
vite.config.ts:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
host: '0.0.0.0', // Wajib untuk Docker
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia'],
},
},
},
},
});
nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache vendor bundle
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API
location /api/ {
proxy_pass http://api:3001/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
docker-compose.yml untuk Development #
# docker-compose.yml
services:
web:
build:
context: .
dockerfile: Dockerfile.dev
image: my-vue-app:dev
container_name: vue-dev
command: npm run dev
ports:
- "5173:5173"
volumes:
- ./:/app
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:3001
depends_on:
- api
api:
build: ./api
image: my-api:dev
container_name: vue-api
ports:
- "3001:3001"
environment:
- DATABASE_URL=postgresql://app:dev@db:5432/myapp
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=myapp
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
Dockerfile.dev:
FROM node:20-alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
Composition API #
Composition API adalah cara modern menulis Vue 3. Lebih fleksibel dari Options API untuk logika kompleks.
<!-- src/components/Counter.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() {
count.value++;
}
onMounted(() => {
console.log('Counter mounted, initial value:', count.value);
});
</script>
<template>
<div class="counter">
<p>Count: {{ count }} (double: {{ doubleCount }})</p>
<button @click="increment">Tambah</button>
</div>
</template>
<style scoped>
.counter {
padding: 1rem;
border: 1px solid #ccc;
}
</style>
Props dengan TypeScript:
<!-- src/components/UserCard.vue -->
<script setup lang="ts">
interface User {
id: number;
name: string;
email: string;
}
interface Props {
user: User;
showEmail?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showEmail: true,
});
const emit = defineEmits<{
(e: 'delete', id: number): void;
}>();
function handleDelete() {
emit('delete', props.user.id);
}
</script>
<template>
<div class="user-card">
<h3>{{ user.name }}</h3>
<p v-if="showEmail">{{ user.email }}</p>
<button @click="handleDelete">Hapus</button>
</div>
</template>
Composables untuk Reusable Logic #
Composables adalah fungsi yang menggunakan Composition API. Dipakai untuk extract logic yang dipakai di banyak komponen.
// src/composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue';
export function useLocalStorage<T>(key: string, initialValue: T): Ref<T> {
const stored = localStorage.getItem(key);
const value = ref<T>(stored ? JSON.parse(stored) : initialValue);
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue));
});
return value;
}
// src/composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue';
export function useFetch<T>(url: Ref<string> | string) {
const data = ref<T | null>(null);
const loading = ref(true);
const error = ref<Error | null>(null);
watchEffect(async () => {
loading.value = true;
try {
const response = await fetch(typeof url === 'string' ? url : url.value);
data.value = await response.json();
error.value = null;
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
});
return { data, loading, error };
}
Pakai di komponen:
<script setup lang="ts">
import { ref } from 'vue';
import { useLocalStorage } from '@/composables/useLocalStorage';
import { useFetch } from '@/composables/useFetch';
const theme = useLocalStorage<string>('theme', 'light');
const apiUrl = ref('/api/users');
const { data, loading, error } = useFetch<User[]>(apiUrl);
</script>
<template>
<div :class="theme">
<p v-if="loading">Loading...</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<ul v-else-if="data">
<li v-for="user in data" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>
State Management dengan Pinia #
Pinia adalah state management resmi Vue 3. Menggantikan Vuex dengan API yang lebih simpel dan TypeScript-first.
npm install pinia
// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
Setup store (recommended):
// src/stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
interface User {
id: number;
name: string;
email: string;
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const token = ref<string | null>(null);
const isAuthenticated = computed(() => user.value !== null);
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
user.value = data.user;
token.value = data.token;
}
function logout() {
user.value = null;
token.value = null;
}
return { user, token, isAuthenticated, login, logout };
});
Options store (alternatif):
// src/stores/cart.ts
import { defineStore } from 'pinia';
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
interface CartState {
items: CartItem[];
}
export const useCartStore = defineStore('cart', {
state: (): CartState => ({ items: [] }),
getters: {
total: (state) => state.items.reduce((sum, i) => sum + i.price * i.qty, 0),
itemCount: (state) => state.items.reduce((sum, i) => sum + i.qty, 0),
},
actions: {
add(item: Omit<CartItem, 'qty'>) {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.qty++;
} else {
this.items.push({ ...item, qty: 1 });
}
},
remove(id: number) {
this.items = this.items.filter(i => i.id !== id);
},
clear() {
this.items = [];
},
},
});
Pakai di komponen:
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useCartStore } from '@/stores/cart';
import { storeToRefs } from 'pinia';
const auth = useAuthStore();
const cart = useCartStore();
// storeToRefs untuk reactivity pada state/getters
const { user, isAuthenticated } = storeToRefs(auth);
const { total, itemCount } = storeToRefs(cart);
// Actions bisa dipanggil langsung
const { login, logout } = auth;
const { add, remove } = cart;
</script>
<template>
<div>
<div v-if="isAuthenticated">
<p>Halo, {{ user.name }}!</p>
<button @click="logout">Logout</button>
</div>
<div v-else>
<button @click="login('[email protected]', 'pass')">Login</button>
</div>
<p>Cart: {{ itemCount }} items — Rp {{ total.toLocaleString('id-ID') }}</p>
<button @click="add({ id: 1, name: 'Produk', price: 50000 })">
Tambah ke Cart
</button>
</div>
</template>
Gunakan storeToRefs untuk state dan getters — ini mempertahankan reactivity saat destructuring. Untuk actions, destructuring langsung tidak masalah.
Vue Router #
npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/products',
name: 'products',
component: () => import('@/views/ProductsView.vue'),
},
{
path: '/products/:id',
name: 'product-detail',
component: () => import('@/views/ProductDetailView.vue'),
props: true,
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/views/NotFoundView.vue'),
},
],
});
export default router;
Pakai di App.vue:
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>
<template>
<header>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/products">Produk</RouterLink>
</nav>
</header>
<main>
<RouterView />
</main>
</template>
Build dan Run #
# Development
docker compose up --build
# Akses: http://localhost:5173
# Production build
docker build -t my-vue-app:prod -f Dockerfile .
docker run -p 8080:80 my-vue-app:prod
# Lihat log
docker compose logs -f web
# Stop
docker compose down
Best Practice #
1. Pisahkan Dockerfile Dev dan Prod #
Dockerfile.dev untuk Vite dev server, Dockerfile multi-stage untuk production nginx. Mode dev butuh node_modules lengkap.
2. Vite host: '0.0.0.0'
#
Wajib di vite.config.ts. Tanpa ini, Vite hanya listen ke localhost di dalam container.
3. Pakai <script setup> SFC
#
<script setup> lebih ringkas dari Composition API biasa. Type inference bekerja lebih baik, dan defineProps/defineEmits jadi macro.
4. Gunakan storeToRefs untuk Destructuring
#
// ✗ Hilang reactivity
const { user } = useAuthStore();
// ✓ Reactive
const { user } = storeToRefs(useAuthStore());
5. Lazy Load Route Component #
component: () => import('@/views/ProductsView.vue')
Vite otomatis code-split per route, mempercepat initial load.
6. Composables untuk Logic yang Reusable #
Jangan duplikasi logic di banyak komponen. Extract ke composables/.
7. TypeScript untuk Type Safety #
Vue 3 + TypeScript = DX terbaik. Definisikan interface untuk props, emits, dan store state.
8. State vs Composables vs Pinia #
| Lokasi | Pakai untuk |
|---|---|
ref/reactive lokal |
State yang hanya dipakai 1 komponen |
provide/inject |
State yang dipakai subtree (form section) |
| Composables | Logic yang dipakai di banyak komponen |
| Pinia | State global (auth, cart, user preferences) |
9. Pinia vs Vuex #
Vuex sudah deprecated untuk Vue 3. Pakai Pinia.
10. Vite Plugin @vitejs/plugin-vue
#
Plugin resmi untuk compile SFC. Jangan pakai compiler lain untuk .vue files.
Troubleshooting #
HMR Tidak Update #
Pastikan host: '0.0.0.0' dan bind mount source code aktif.
“Failed to resolve component” #
Biasanya auto-import belum aktif atau nama komponen salah. Cek vite.config.ts components option atau import manual.
Pinia Store Tidak Reactive #
Pastikan pakai storeToRefs saat destructuring state/getters.
Build Gagal dengan “Rollup parse error” #
Cek vite.config.ts plugin order. @vitejs/plugin-vue harus sebelum plugin lain yang memproses .vue.
Port 5173 Konflik #
Ubah port mapping di Compose atau stop proses:
lsof -i :5173
Ringkasan #
- Vue 3 ideal untuk SPA dengan Vite + Pinia. Image production ramping (~30MB nginx + static).
- Dockerfile multi-stage dengan nginx static serving. Tahap
depsinstall,builderbuild Vite,runnerservedist/.- Vite HMR berbasis native ESM — instan untuk perubahan SFC. Pastikan
host: '0.0.0.0'.- Composition API dengan
<script setup>adalah standar modern Vue 3. Lebih ringkas, type inference lebih baik.- Composables di
src/composables/extract reusable logic. Naming conventionuseXxx.- Pinia adalah state management resmi Vue 3. Setup store atau options store. Pakai
storeToRefsuntuk destructuring reactive.- Vue Router 4 dengan lazy-loaded routes untuk code splitting otomatis. Pakai
RouterViewdi root.- Anonymous volume untuk
node_modulesmencegah konflik binary native dengan host.- Environment variable hanya
VITE_*yang masuk bundle. Secret harus di backend.- Dockerfile dev sederhana, Dockerfile prod multi-stage. Jangan campur.
- Lazy load route dengan
() => import('@/views/...')mempercepat initial load.- Pilih state management sesuai skala: lokal (ref), composables, Pinia untuk global.
- Troubleshooting: HMR mati (cek host), component error (cek auto-import), Pinia reactivity (storeToRefs), port conflict.
- Vite plugin
@vitejs/plugin-vuewajib. TypeScript inference optimal denganvue-tscuntuk type check.- TypeScript untuk semua component, store, dan composable. Interface untuk props, emits, state.