React #
React adalah library UI yang fokus pada tampilan. Tidak ada framework routing bawaan, tidak ada SSR/SSG, dan tidak ada state management global. Semua keputusan ini diserahkan ke developer. Untuk local development, pilihan paling umum adalah Vite + React — dev server super cepat, HMR instan, dan build output optimal untuk di-serve dengan nginx.
Docker untuk React ideal untuk memastikan konsistensi environment di tim — versi Node sama, dependency native terisolasi, dan onboarding developer baru tidak perlu install Node atau pnpm. Artikel ini membahas setup lengkap, dari Vite dev server di Docker, state management, hingga production image dengan nginx.
Prasyarat #
Pastikan sudah terinstall:
- Docker dan Docker Compose versi terbaru
- Node.js 20+ (opsional, untuk tooling di host)
Project React dengan Vite:
my-react-app/
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── components/
│ ├── hooks/
│ ├── pages/
│ ├── store/
│ └── index.css
├── public/
├── index.html
├── package.json
├── package-lock.json
├── vite.config.ts
├── tsconfig.json
├── Dockerfile
├── docker-compose.yml
└── .env
Mengapa Vite untuk React #
Vite menggantikan Create React App (CRA) yang sudah deprecated. Alasan utama:
| Aspek | Vite | CRA (Webpack) |
|---|---|---|
| Startup dev | ~300ms | ~3-10 detik |
| HMR | Instan | Lambat untuk app besar |
| Build | Rollup (optimized) | Webpack |
| Konfigurasi | Minimal | Banyak |
Dockerfile Multi-Stage #
React + Vite menghasilkan build statis (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 React ----
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 react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: '0.0.0.0', // Penting untuk Docker
},
build: {
outDir: 'dist',
sourcemap: true,
},
});
Tanpahost: '0.0.0.0'divite.config.ts, Vite hanya listen kelocalhostdi dalam container. Browser di host tidak akan bisa akses aplikasi. Flag--hostdi CLI juga bisa, tapi konfigurasi file lebih reliable.
nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache static assets agresif
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback — semua route unknown ke index.html
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API ke backend (opsional)
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-react-app:dev
container_name: react-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: react-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"]
Penjelasan Service #
web — service Vite dev server. Source code bind-mount, node_modules di-isolasi. Port 5173 (default Vite) di-expose ke host.
api — backend terpisah yang handle /api/. Di production, nginx proxy ke service ini.
db — Postgres untuk data persisten.
Hot Reload dengan Vite HMR #
Vite HMR berbasis native ESM. Setiap edit di src/ langsung update browser tanpa refresh.
// vite.config.ts — pastikan HMR pakai port yang sama
export default defineConfig({
server: {
port: 5173,
host: '0.0.0.0',
hmr: {
// Port HMR WebSocket — default 5173
clientPort: 5173,
},
},
});
Untuk Docker, port HMR otomatis mengikuti server port. Yang perlu dipastikan hanya host: '0.0.0.0'.
Komponen dan JSX #
React 19 membawa komponen function dengan hooks:
// src/components/UserCard.tsx
import { useState } from 'react';
interface User {
id: number;
name: string;
email: string;
}
interface UserCardProps {
user: User;
onDelete?: (id: number) => void;
}
export function UserCard({ user, onDelete }: UserCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="user-card">
<h3 onClick={() => setIsExpanded(!isExpanded)}>
{user.name}
</h3>
{isExpanded && <p>{user.email}</p>}
{onDelete && (
<button onClick={() => onDelete(user.id)}>Hapus</button>
)}
</div>
);
}
Hooks Built-in #
Hooks adalah inti React. Lima yang paling sering dipakai:
// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// src/hooks/useFetch.ts
import { useState, useEffect } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
});
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(data => {
if (!cancelled) setState({ data, loading: false, error: null });
})
.catch(error => {
if (!cancelled) setState({ data: null, loading: false, error });
});
return () => { cancelled = true; };
}, [url]);
return state;
}
Untuk data fetching modern, pertimbangkan TanStack Query atau SWR — mereka handle caching, retry, dan refetch otomatis. useFetch di atas cukup untuk demo, tapi untuk aplikasi produksi, library ini menghemat banyak kode.
State Management #
React 19 tidak punya state global bawaan. Pilihannya:
Context API untuk State Sederhana #
// src/contexts/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
interface User {
id: number;
name: string;
email: string;
}
interface AuthContextValue {
user: User | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(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();
setUser(data.user);
}
function logout() {
setUser(null);
}
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
Pakai di komponen:
// src/components/Navbar.tsx
import { useAuth } from '../contexts/AuthContext';
export function Navbar() {
const { user, logout } = useAuth();
return (
<nav>
{user ? (
<>
<span>Halo, {user.name}</span>
<button onClick={logout}>Logout</button>
</>
) : (
<a href="/login">Login</a>
)}
</nav>
);
}
Context API tidak cocok untuk state yang sering berubah (seperti form input, drag-and-drop position). Setiap perubahan Context akan re-render semua komponen yang consume, yang bisa menjadi bottleneck performa. Untuk state seperti ini, pakai state lokal atau library seperti Zustand.
Zustand untuk State Global #
Zustand adalah library state management yang ringan (1KB) dan simpel:
npm install zustand
// src/store/cart.ts
import { create } from 'zustand';
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
interface CartState {
items: CartItem[];
add: (item: Omit<CartItem, 'qty'>) => void;
remove: (id: number) => void;
clear: () => void;
total: () => number;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
add: (item) => set((state) => {
const existing = state.items.find(i => i.id === item.id);
if (existing) {
return {
items: state.items.map(i =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
),
};
}
return { items: [...state.items, { ...item, qty: 1 }] };
}),
remove: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}));
Pakai di komponen — tidak perlu provider:
// src/components/CartButton.tsx
import { useCartStore } from '../store/cart';
export function CartButton() {
const itemCount = useCartStore((state) => state.items.length);
const add = useCartStore((state) => state.add);
return (
<button onClick={() => add({ id: 1, name: 'Produk', price: 50000 })}>
Cart ({itemCount})
</button>
);
}
Redux Toolkit untuk State Kompleks #
Untuk aplikasi besar dengan banyak slice dan middleware:
npm install @reduxjs/toolkit react-redux
// src/store/productsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
interface Product {
id: number;
name: string;
price: number;
}
interface ProductsState {
items: Product[];
loading: boolean;
error: string | null;
}
const initialState: ProductsState = {
items: [],
loading: false,
error: null,
};
export const fetchProducts = createAsyncThunk(
'products/fetch',
async () => {
const res = await fetch('/api/products');
return res.json() as Promise<Product[]>;
}
);
const productsSlice = createSlice({
name: 'products',
initialState,
reducers: {
addProduct: (state, action: PayloadAction<Product>) => {
state.items.push(action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state) => {
state.loading = true;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchProducts.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message ?? 'Unknown error';
});
},
});
export const { addProduct } = productsSlice.actions;
export default productsSlice.reducer;
Routing dengan React Router #
React tidak punya router bawaan. React Router adalah pilihan standar:
npm install react-router-dom
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
// src/App.tsx
import { Routes, Route, Link } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { ProductsPage } from './pages/ProductsPage';
import { ProductDetail } from './pages/ProductDetail';
export function App() {
return (
<>
<nav>
<Link to="/">Home</Link>
<Link to="/products">Produk</Link>
</nav>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/products/:id" element={<ProductDetail />} />
</Routes>
</>
);
}
Build dan Run #
# Development
docker compose up --build
# Akses: http://localhost:5173
# Production build
docker build -t my-react-app:prod -f Dockerfile .
docker run -p 8080:80 my-react-app:prod
# Akses: http://localhost:8080
# Lihat log
docker compose logs -f web
# Stop
docker compose down
Best Practice #
1. Pisahkan Dockerfile Dev dan Prod #
Dockerfile.dev untuk dev (Vite + HMR), Dockerfile untuk production (multi-stage + nginx). Mode dev perlu node_modules lengkap, production hanya butuh dist/.
2. Vite host: '0.0.0.0'
#
Wajib di vite.config.ts agar dev server bisa diakses dari host. Tanpa ini, hanya listen ke localhost di dalam container.
3. Anonymous Volume untuk node_modules
#
volumes:
- /app/node_modules
Tanpa ini, node_modules dari host akan menutupi versi container, dan binary native (untuk Vite plugin) sering gagal.
4. State Management Pilih Sesuai Skala #
| Skala | Pilihan |
|---|---|
| Kecil (1-5 halaman) | useState + useContext |
| Menengah (5-20 halaman) | Zustand |
| Besar (20+ halaman, tim besar) | Redux Toolkit |
5. Pakai TanStack Query untuk Server State #
Jangan simpan data API di Redux/Zustand untuk data yang hanya “tampil”. Pakai TanStack Query untuk caching, refetch, dan invalidation otomatis.
6. Code Splitting dengan React.lazy
#
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
export function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
Vite otomatis membuat chunk terpisah untuk setiap lazy() import, mempercepat initial load.
7. Environment Variable untuk Vite #
Vite hanya baca variable yang dimulai dengan VITE_:
# .env
VITE_API_URL=http://localhost:3001
VITE_PUBLIC_KEY=abc123
// Dipakai di kode
const apiUrl = import.meta.env.VITE_API_URL;
Jangan simpan secret di VITE_* — variable ini ter-bundle ke client dan bisa dilihat semua orang. Pakai backend untuk secret.
Troubleshooting #
HMR Tidak Update #
Pastikan host: '0.0.0.0' di Vite config dan bind mount source code aktif. Cek docker compose exec web ls -la src untuk memastikan file ada.
“Cannot connect to API” #
CORS issue. Backend harus izinkan origin frontend:
// Backend Express.js
app.use(cors({ origin: 'http://localhost:5173' }));
Atau proxy lewat Vite:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/api': 'http://api:3001',
},
},
});
Build Output Kosong #
Cek vite.config.ts build.outDir. Default dist/. Jika Dockerfile menyalin dari path lain, output tidak ditemukan.
Port 5173 Konflik #
lsof -i :5173
Ubah port mapping Compose: "5174:5173". Akses di http://localhost:5174.
Ringkasan #
- React ideal untuk SPA dengan Vite + nginx. Image production ramping, dev server cepat.
- Dockerfile multi-stage dengan nginx static serving. Tahap
depsinstall,builderbuild dengan Vite,runnerservedist/dengan nginx.- Vite HMR berbasis native ESM — instan untuk perubahan file. Pastikan
host: '0.0.0.0'di config.- State management bertingkat:
useState/useContextuntuk kecil, Zustand untuk menengah, Redux Toolkit untuk besar.- Pakai TanStack Query untuk server state (data API) — caching, refetch, invalidation otomatis.
- Routing dengan React Router DOM. Code splitting dengan
React.lazy()untuk chunk per halaman.- Anonymous volume untuk
node_modulesmencegah konflik binary native dengan host.- Environment variable hanya
VITE_*yang masuk bundle. Secret harus di backend, bukan frontend.- Dockerfile dev sederhana (Vite + npm), Dockerfile prod multi-stage (Node build + nginx serve).
- Best practice: pisahkan dev/prod Dockerfile, Vite host config, anonymous volume, lazy loading, CORS atau proxy.
- Troubleshooting: HMR mati (cek host config), CORS error (proxy atau allow origin), port conflict (ubah mapping).
- Code splitting dengan
React.lazy()mempercepat initial load untuk aplikasi multi-halaman.- Performance: hindari simpan server state di Context, pakai Zustand selector untuk narrow subscription.