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
- React component architecture (containers vs presentational)
- State management với Zustand (lightweight Redux alternative)
- Data fetching với TanStack Query (React Query)
- Form handling với react-hook-form + Zod
- 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-domFolder 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
| Layer | Tool | Purpose |
|---|---|---|
| API Client | Axios + Interceptors | Token management, refresh |
| State | Zustand + Persist | Global state (auth) |
| Server State | TanStack Query | API caching, mutations |
| Forms | react-hook-form + Zod | Validation, type-safe |
| Styling | CSS Modules / Tailwind | Responsive design |
⏭️ Buổi tiếp theo
Buổi 11: Full-Stack Integration — API ↔ Frontend 🔗