React #

React is a UI library focused on views. There’s no built-in routing framework, no SSR/SSG, and no global state management. All these decisions are left to the developer. For local development, the most common choice is Vite + React — a super-fast dev server, instant HMR, and optimal build output for serving with nginx.

Docker for React is ideal for ensuring environment consistency across the team — the same Node version, isolated native dependencies, and new developer onboarding without installing Node or pnpm. This article covers the complete setup, from the Vite dev server in Docker, state management, to the production image with nginx.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Node.js 20+ (optional, for host tooling)

A React project with 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

Why Vite for React #

Vite replaces Create React App (CRA), which is deprecated. The main reasons:

AspectViteCRA (Webpack)
Dev startup~300ms~3-10 seconds
HMRInstantSlow for large apps
BuildRollup (optimized)Webpack
ConfigurationMinimalLots

Multi-Stage Dockerfile #

React + Vite produces a static build (HTML + JS + CSS). The production image only needs 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 with 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', // Important for Docker
  },
  build: {
    outDir: 'dist',
    sourcemap: true,
  },
});
Without host: '0.0.0.0' in vite.config.ts, Vite only listens to localhost inside the container. The host browser won’t be able to access the app. The --host CLI flag also works, but file configuration is more reliable.

nginx.conf:

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Cache static assets aggressively
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA fallback — all unknown routes to index.html
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Proxy the API to the backend (optional)
    location /api/ {
        proxy_pass http://api:3001/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

docker-compose.yml for 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:pass@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"]

Service Explanations #

web — the Vite dev server service. Source code is bind-mounted, node_modules is isolated. Port 5173 (Vite’s default) is exposed to the host.

api — a separate backend handling /api/. In production, nginx proxies to this service.

db — Postgres for persistent data.


Hot Reload with Vite HMR #

Vite HMR is based on native ESM. Every edit in src/ updates the browser instantly without a refresh.

// vite.config.ts — make sure HMR uses the same port
export default defineConfig({
  server: {
    port: 5173,
    host: '0.0.0.0',
    hmr: {
      // HMR WebSocket port — default 5173
      clientPort: 5173,
    },
  },
});

For Docker, the HMR port automatically follows the server port. You only need to make sure host: '0.0.0.0' is set.


Components and JSX #

React 19 brings function components with 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)}>Delete</button>
      )}
    </div>
  );
}

Built-in Hooks #

Hooks are React’s core. The five most commonly used:

// 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;
}
For modern data fetching, consider TanStack Query or SWR — they handle caching, retry, and automatic refetching. The useFetch above is enough for demos, but for production apps, these libraries save a lot of code.

State Management #

React 19 has no built-in global state. The options:

Context API for Simple State #

// 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;
}

Use it in a component:

// src/components/Navbar.tsx
import { useAuth } from '../contexts/AuthContext';

export function Navbar() {
  const { user, logout } = useAuth();
  
  return (
    <nav>
      {user ? (
        <>
          <span>Hello, {user.name}</span>
          <button onClick={logout}>Logout</button>
        </>
      ) : (
        <a href="/login">Login</a>
      )}
    </nav>
  );
}
The Context API isn’t suitable for frequently-changing state (like form inputs, drag-and-drop positions). Every Context change re-renders all consuming components, which can become a performance bottleneck. For this kind of state, use local state or a library like Zustand.

Zustand for Global State #

Zustand is a lightweight (1KB) and simple state management library:

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),
}));

Use it in a component — no provider needed:

// 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: 'Product', price: 50000 })}>
      Cart ({itemCount})
    </button>
  );
}

Redux Toolkit for Complex State #

For large apps with many slices and 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 with React Router #

React has no built-in router. React Router is the standard choice:

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">Products</Link>
      </nav>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/products" element={<ProductsPage />} />
        <Route path="/products/:id" element={<ProductDetail />} />
      </Routes>
    </>
  );
}

Build and Run #

# Development
docker compose up --build
# Access: http://localhost:5173

# Production build
docker build -t my-react-app:prod -f Dockerfile .
docker run -p 8080:80 my-react-app:prod
# Access: http://localhost:8080

# View logs
docker compose logs -f web

# Stop
docker compose down

Best Practices #

1. Separate Dev and Prod Dockerfiles #

Dockerfile.dev for dev (Vite + HMR), Dockerfile for production (multi-stage + nginx). Dev mode needs the full node_modules; production only needs dist/.

2. Vite host: '0.0.0.0' #

Mandatory in vite.config.ts so the dev server is reachable from the host. Without it, it only listens to localhost inside the container.

3. An Anonymous Volume for node_modules #

volumes:
  - /app/node_modules

Without this, the host’s node_modules covers the container’s version, and native binaries (for Vite plugins) often fail.

4. State Management by Scale #

ScaleChoice
Small (1-5 pages)useState + useContext
Medium (5-20 pages)Zustand
Large (20+ pages, big teams)Redux Toolkit

5. Use TanStack Query for Server State #

Don’t store API data in Redux/Zustand for data that’s only “displayed”. Use TanStack Query for caching, refetching, and automatic invalidation.

6. Code Splitting with 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 automatically creates a separate chunk for each lazy() import, speeding up the initial load.

7. Environment Variables for Vite #

Vite only reads variables starting with VITE_:

# .env
VITE_API_URL=http://localhost:3001
VITE_PUBLIC_KEY=abc123
// Used in code
const apiUrl = import.meta.env.VITE_API_URL;
Don’t store secrets in VITE_* — these variables are bundled into the client and visible to everyone. Use the backend for secrets.

Troubleshooting #

HMR Not Updating #

Make sure host: '0.0.0.0' is in the Vite config and the source bind mount is active. Check docker compose exec web ls -la src to confirm the files exist.

“Cannot connect to API” #

A CORS issue. The backend must allow the frontend origin:

// Express.js backend
app.use(cors({ origin: 'http://localhost:5173' }));

Or proxy through Vite:

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': 'http://api:3001',
    },
  },
});

Empty Build Output #

Check build.outDir in vite.config.ts. Default is dist/. If the Dockerfile copies from another path, the output won’t be found.

Port 5173 Conflict #

lsof -i :5173

Change the Compose port mapping: "5174:5173". Access at http://localhost:5174.


Summary #

  • React is ideal for SPAs with Vite + nginx. A slim production image, a fast dev server.
  • Multi-stage Dockerfiles with static nginx serving. The deps stage installs, builder builds with Vite, runner serves dist/ with nginx.
  • Vite HMR is based on native ESM — instant file-change updates. Make sure host: '0.0.0.0' is in the config.
  • Tiered state management: useState/useContext for small, Zustand for medium, Redux Toolkit for large.
  • Use TanStack Query for server state (API data) — caching, refetching, automatic invalidation.
  • Routing with React Router DOM. Code splitting with React.lazy() for per-page chunks.
  • Anonymous volumes for node_modules prevent native binary conflicts with the host.
  • Environment variables — only VITE_* enters the bundle. Secrets belong in the backend, not the frontend.
  • Dev Dockerfiles are simple (Vite + npm), prod Dockerfiles are multi-stage (Node build + nginx serve).
  • Best practices: separate dev/prod Dockerfiles, Vite host config, anonymous volumes, lazy loading, CORS or proxies.
  • Troubleshooting: dead HMR (check host config), CORS errors (proxy or allow origins), port conflicts (change mappings).
  • Code splitting with React.lazy() speeds up initial loads for multi-page apps.
  • Performance: avoid storing server state in Context; use Zustand selectors for narrow subscriptions.

← Previous: Nuxt 3   Next: Vue 3 →

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