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 deps install, builder build Vite, runner serve dist/.
  • 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 convention useXxx.
  • Pinia adalah state management resmi Vue 3. Setup store atau options store. Pakai storeToRefs untuk destructuring reactive.
  • Vue Router 4 dengan lazy-loaded routes untuk code splitting otomatis. Pakai RouterView di root.
  • Anonymous volume untuk node_modules mencegah 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-vue wajib. TypeScript inference optimal dengan vue-tsc untuk type check.
  • TypeScript untuk semua component, store, dan composable. Interface untuk props, emits, state.

← Sebelumnya: React   Berikutnya: Svelte →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact