Skip to content

Buổi 11: Full-Stack Integration — API ↔ Frontend 🔗

Thành quả: Blog app chạy end-to-end: login → create post → read → update → delete


🎯 Mục Tiêu

  1. Connect frontend ↔ backend: CORS, environment variables
  2. Loading states, error handling, optimistic updates
  3. File upload flow (avatar, post images)
  4. WebSocket basics (real-time notifications)
  5. cm-execution mode: batch → parallel

📖 Phần 1: Integration Checklist

Connection Setup

typescript
// Backend: Enable CORS
import cors from 'cors';
app.use(cors({
  origin: process.env.FRONTEND_URL || 'http://localhost:5173',
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// Frontend: Environment variables
// .env.development
VITE_API_URL=http://localhost:3000/api/v1

// .env.production
VITE_API_URL=https://api.myapp.com/api/v1

Integration Verification Script

bash
#!/bin/bash
# verify-integration.sh

echo "🔍 Checking backend..."
curl -s http://localhost:3000/api/v1/health | jq .
echo ""

echo "🔍 Testing auth flow..."
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@test.com","password":"password123"}' \
  | jq -r '.accessToken')

echo "Token: ${TOKEN:0:20}..."

echo "🔍 Testing protected endpoint..."
curl -s http://localhost:3000/api/v1/posts \
  -H "Authorization: Bearer $TOKEN" | jq '.meta'

echo "✅ Integration OK!"

📖 Phần 2: Loading & Error States

Pattern: 3 States Component

tsx
// Every data component handles 3 states:
function PostList() {
  const { data, isLoading, isError, error, refetch } = usePosts();

  // State 1: Loading
  if (isLoading) {
    return (
      <div className="grid grid-cols-3 gap-4">
        {[...Array(6)].map((_, i) => (
          <div key={i} className="skeleton h-48 rounded-lg animate-pulse bg-gray-200" />
        ))}
      </div>
    );
  }

  // State 2: Error
  if (isError) {
    return (
      <div className="error-state p-8 text-center">
        <AlertCircle className="mx-auto mb-4 text-red-500" size={48} />
        <h3>Failed to load posts</h3>
        <p className="text-gray-500">{error.message}</p>
        <button onClick={() => refetch()} className="btn mt-4">
          Try Again
        </button>
      </div>
    );
  }

  // State 3: Empty
  if (!data?.data.length) {
    return (
      <div className="empty-state p-8 text-center">
        <FileText className="mx-auto mb-4 text-gray-400" size={48} />
        <h3>No posts yet</h3>
        <Link to="/posts/new" className="btn btn-primary mt-4">
          Create First Post
        </Link>
      </div>
    );
  }

  // State 4: Success (data)
  return (
    <div className="grid grid-cols-3 gap-4">
      {data.data.map(post => <PostCard key={post.id} post={post} />)}
    </div>
  );
}

Optimistic Updates

typescript
export function useDeletePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: postService.delete,
    // Optimistic: remove from list immediately
    onMutate: async (postId) => {
      await queryClient.cancelQueries({ queryKey: ['posts'] });
      const previous = queryClient.getQueryData(['posts']);
      
      queryClient.setQueryData(['posts'], (old: any) => ({
        ...old,
        data: old.data.filter((p: Post) => p.id !== postId),
      }));
      
      return { previous };
    },
    // Rollback on error
    onError: (err, postId, context) => {
      queryClient.setQueryData(['posts'], context?.previous);
      toast.error('Failed to delete post');
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
}

📖 Phần 3: File Upload

Backend: Multer Setup

typescript
// middleware/upload.middleware.ts
import multer from 'multer';
import path from 'path';

const storage = multer.diskStorage({
  destination: 'uploads/',
  filename: (req, file, cb) => {
    const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
    cb(null, uniqueSuffix + path.extname(file.originalname));
  },
});

const fileFilter = (req: any, file: Express.Multer.File, cb: any) => {
  const allowed = ['image/jpeg', 'image/png', 'image/webp'];
  if (allowed.includes(file.mimetype)) {
    cb(null, true);
  } else {
    cb(new AppError('Only JPEG, PNG, and WebP images allowed', 400), false);
  }
};

export const upload = multer({
  storage,
  fileFilter,
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
});

// Route
router.post('/upload', authenticate, upload.single('image'), uploadController.handleUpload);

Frontend: Upload Component

tsx
function ImageUpload({ onUpload }: { onUpload: (url: string) => void }) {
  const [preview, setPreview] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);

  const handleFile = async (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // Preview
    setPreview(URL.createObjectURL(file));
    
    // Upload
    setUploading(true);
    const formData = new FormData();
    formData.append('image', file);
    
    try {
      const { data } = await api.post('/upload', formData, {
        headers: { 'Content-Type': 'multipart/form-data' },
      });
      onUpload(data.url);
      toast.success('Image uploaded!');
    } catch {
      toast.error('Upload failed');
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="upload-zone">
      <input type="file" accept="image/*" onChange={handleFile} />
      {preview && <img src={preview} alt="Preview" className="preview" />}
      {uploading && <Spinner />}
    </div>
  );
}

📖 Phần 4: Environment & Deployment Config

project/
├── backend/
│   ├── .env.development    # DATABASE_URL=localhost, JWT_SECRET=dev
│   ├── .env.production     # ⚠️ Never commit!
│   └── .env.example        # Template for new devs
├── frontend/
│   ├── .env.development    # VITE_API_URL=http://localhost:3000
│   ├── .env.production     # VITE_API_URL=https://api.prod.com
│   └── .env.example
└── docker-compose.yml      # PostgreSQL + Redis local

Docker Compose for Local Dev

yaml
# docker-compose.yml
version: '3.8'
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: blogapp
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: devpass
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

📖 Phần 5: cm-execution Modes

ModeUse CaseSpeed
SequentialDependent tasks (auth → CRUD → test)Normal
BatchGroup of related small tasks2x
ParallelIndependent tasks (frontend + backend)3x
# Parallel execution example
cm-execution --mode parallel \
  --task-1 "Generate backend API for comments" \
  --task-2 "Generate frontend PostDetail component" \
  --task-3 "Generate test suite for post service"

🧪 Lab: End-to-End Integration

Task: Wire it all together (90 min)

Step 1: Start backend + database
  docker-compose up -d
  cd backend && npm run dev

Step 2: Start frontend
  cd frontend && npm run dev

Step 3: Test full flow
  → Register user → Login → Create post → View post
  → Edit post → Delete post → Logout → Verify redirect

Step 4: Error scenarios
  → Submit invalid form → see validation errors
  → Access protected page without login → redirect
  → Make API call with expired token → auto-refresh
  → Upload too-large file → see error message

Step 5: Screenshot evidence
  → Capture each flow step
  → Document in integration-test.md

🎓 Tóm Tắt

Integration LayerPattern
ConnectionCORS + env vars + health check
Loading3 states: loading, error, empty, data
OptimisticUpdate UI first, rollback on error
File UploadMulter backend + FormData frontend
Dev SetupDocker Compose for local services

⏭️ Buổi tiếp theo

Buổi 12: Database & Performance — Query Optimization 📊

Powered by CodyMaster × VitePress