Skip to content

Buổi 10: Frontend Modern — React + State Management 🎨

Thành quả: Build React frontend kết nối API: auth flow + CRUD views + responsive design


🎯 Mục Tiêu

  1. React component architecture (containers vs presentational)
  2. State management với Zustand (lightweight Redux alternative)
  3. Data fetching với TanStack Query (React Query)
  4. Form handling với react-hook-form + Zod
  5. Responsive design với CSS Modules / Tailwind

📖 Phần 1: Project Setup

Vite + React + TypeScript

bash
npx create-vite@latest frontend -- --template react-ts
cd frontend
npm install

# Essential dependencies
npm install axios zustand @tanstack/react-query react-hook-form @hookform/resolvers zod
npm install react-router-dom@6 react-hot-toast lucide-react

# Dev dependencies
npm install -D @types/node vitest @testing-library/react @testing-library/jest-dom

Folder Structure

src/
├── components/          # Reusable UI components
│   ├── ui/             # Base components (Button, Input, Card)
│   ├── layout/         # Header, Footer, Sidebar
│   └── forms/          # Form-specific components
├── features/           # Feature modules
│   ├── auth/           # Login, Register, AuthGuard
│   ├── posts/          # PostList, PostDetail, PostForm
│   └── dashboard/      # Dashboard, Stats
├── hooks/              # Custom hooks
├── services/           # API client & service layer
├── stores/             # Zustand stores
├── types/              # TypeScript types
├── utils/              # Helper functions
├── App.tsx
└── main.tsx

📖 Phần 2: API Service Layer

Axios Instance

typescript
// services/api.ts
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  headers: { 'Content-Type': 'application/json' },
});

// Request interceptor — attach token
api.interceptors.request.use((config) => {
  const token = useAuthStore.getState().accessToken;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response interceptor — handle token refresh
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config;
    
    if (error.response?.status === 401 && !original._retry) {
      original._retry = true;
      try {
        const { refreshToken } = useAuthStore.getState();
        const { data } = await axios.post('/api/v1/auth/refresh', { refreshToken });
        useAuthStore.getState().setTokens(data.accessToken, data.refreshToken);
        original.headers.Authorization = `Bearer ${data.accessToken}`;
        return api(original);
      } catch {
        useAuthStore.getState().logout();
        window.location.href = '/login';
      }
    }
    return Promise.reject(error);
  }
);

export default api;

Service Pattern

typescript
// services/post.service.ts
import api from './api';
import { Post, CreatePostInput, PaginatedResponse } from '@/types';

export const postService = {
  getAll: async (params?: { page?: number; status?: string }) => {
    const { data } = await api.get<PaginatedResponse<Post>>('/posts', { params });
    return data;
  },

  getBySlug: async (slug: string) => {
    const { data } = await api.get<{ data: Post }>(`/posts/${slug}`);
    return data.data;
  },

  create: async (input: CreatePostInput) => {
    const { data } = await api.post<{ data: Post }>('/posts', input);
    return data.data;
  },

  update: async (id: string, input: Partial<CreatePostInput>) => {
    const { data } = await api.put<{ data: Post }>(`/posts/${id}`, input);
    return data.data;
  },

  delete: async (id: string) => {
    await api.delete(`/posts/${id}`);
  },
};

📖 Phần 3: State Management — Zustand

Auth Store

typescript
// stores/auth.store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
  setTokens: (access: string, refresh: string) => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,

      login: async (email, password) => {
        const { data } = await api.post('/auth/login', { email, password });
        set({
          user: data.user,
          accessToken: data.accessToken,
          refreshToken: data.refreshToken,
          isAuthenticated: true,
        });
      },

      logout: () => {
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
        });
      },

      setTokens: (accessToken, refreshToken) => {
        set({ accessToken, refreshToken });
      },
    }),
    { name: 'auth-storage' }
  )
);

📖 Phần 4: Data Fetching — TanStack Query

typescript
// hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { postService } from '@/services/post.service';

export function usePosts(params?: { page?: number; status?: string }) {
  return useQuery({
    queryKey: ['posts', params],
    queryFn: () => postService.getAll(params),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

export function usePost(slug: string) {
  return useQuery({
    queryKey: ['post', slug],
    queryFn: () => postService.getBySlug(slug),
    enabled: !!slug,
  });
}

export function useCreatePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: postService.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      toast.success('Post created!');
    },
    onError: (error: AxiosError<ApiError>) => {
      toast.error(error.response?.data?.error?.message || 'Failed to create post');
    },
  });
}

Component Usage

tsx
// features/posts/PostList.tsx
export function PostList() {
  const [page, setPage] = useState(1);
  const { data, isLoading, isError } = usePosts({ page });

  if (isLoading) return <PostListSkeleton />;
  if (isError) return <ErrorMessage message="Failed to load posts" />;

  return (
    <div className="post-grid">
      {data?.data.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
      <Pagination
        current={page}
        total={data?.meta.totalPages || 1}
        onChange={setPage}
      />
    </div>
  );
}

📖 Phần 5: Form Handling

tsx
// features/posts/PostForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const postSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  content: z.string().min(50, 'Content must be at least 50 characters'),
  status: z.enum(['DRAFT', 'PUBLISHED']),
});

type PostFormData = z.infer<typeof postSchema>;

export function PostForm({ onSubmit }: { onSubmit: (data: PostFormData) => void }) {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<PostFormData>({
    resolver: zodResolver(postSchema),
    defaultValues: { status: 'DRAFT' },
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" {...register('title')} className="input" />
        {errors.title && <span className="error">{errors.title.message}</span>}
      </div>

      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" {...register('content')} rows={10} className="textarea" />
        {errors.content && <span className="error">{errors.content.message}</span>}
      </div>

      <div>
        <select {...register('status')}>
          <option value="DRAFT">Draft</option>
          <option value="PUBLISHED">Published</option>
        </select>
      </div>

      <button type="submit" disabled={isSubmitting} className="btn btn-primary">
        {isSubmitting ? 'Saving...' : 'Save Post'}
      </button>
    </form>
  );
}

🧪 Lab: Build Blog Frontend

Task: Complete React Frontend (90 min)

Phase 1: Setup + Routing (15 min)
→ Vite + React + Router setup
→ Layout: Header + Sidebar + Content

Phase 2: Auth Flow (20 min)
→ Login page (form + validation + Zustand)
→ Register page
→ AuthGuard component (redirect if not logged in)

Phase 3: Posts CRUD (40 min)
→ PostList (paginated, with loading/error states)
→ PostDetail (markdown rendering)
→ PostForm (create + edit mode)
→ Delete confirmation

Phase 4: Polish (15 min)
→ Responsive design (mobile menu)
→ Toast notifications
→ Loading skeletons

🎓 Tóm Tắt

LayerToolPurpose
API ClientAxios + InterceptorsToken management, refresh
StateZustand + PersistGlobal state (auth)
Server StateTanStack QueryAPI caching, mutations
Formsreact-hook-form + ZodValidation, type-safe
StylingCSS Modules / TailwindResponsive design

⏭️ Buổi tiếp theo

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

Powered by CodyMaster × VitePress