Bỏ qua

React Router v6 — "Người Dẫn Đường" Tận Tụy (Client-side Routing) 🗺️

Web 1 Trang (SPA) và Routing là gì?

Cách làm cũ (Web Đa Trang - MPA): Bạn bấm vào link "/about" → Trình duyệt sẽ chạy lên máy chủ xin nguyên một trang mới → Máy chủ trả về nguyên cục HTML mới. → Hậu quả: Trang web bị chớp trắng tải lại từ đầu, bạn đang cuộn chuột ở đâu sẽ bị mất vị trí, dữ liệu đang gõ dở cũng bay màu.

Cách làm hiện đại (Web Một Trang - SPA bằng React): Bạn bấm link "/about" → JavaScript nhảy ra cản lại → Tự động thay đổi nội dung (component) ngay trên màn hình. → Kết quả: Không hề tải lại trang, mượt mà như đang dùng app trên điện thoại, mọi dữ liệu được giữ nguyên vẹn.

Trong dự án này, máy chủ Cloudflare Pages chỉ ném đúng một file index.html duy nhất cho mọi đường link. Còn việc "đường link này thì hiện màn hình nào" sẽ do thư viện React Router tự phân luồng ngay trên trình duyệt.


Lên Khung Cơ Bản 🏗️

File main.tsx thường chỉ để chứa các công cụ dùng chung (như TanStack Query). Chúng ta sẽ đặt công cụ điều hướng BrowserRouter và danh sách các con đường (Routes) trực tiếp vào file App.tsx cho gọn:

// web/src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { retry: 1, staleTime: 30_000 },
  },
});

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>,
);
// web/src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';
// ... (Import các trang giao diện vào đây)

export default function App() {
  const { accessToken, role, groups } = useAuthStore();

  // Hàm "Cảnh sát giao thông" — Chỉ đường tự động khi user vào trang chủ "/"
  const defaultRedirect = () => {
    if (!accessToken) return '/login'; // Chưa có thẻ? Đi đăng nhập!
    if (role === 'super_admin') return '/admin'; // Sếp lớn? Vào phòng VIP!
    if (groups.length === 1) return `/group/${groups[0].id}`; // Có đúng 1 nhóm? Vào thẳng nhóm đó luôn!
    return '/groups'; // Còn lại thì ra trang danh sách nhóm.
  };

  return (
    <BrowserRouter>
      <Routes>
        {/* Đường dẫn mặc định (Phụ thuộc vào việc bạn là ai) */}
        <Route path="/" element={<Navigate to={defaultRedirect()} replace />} />

        {/* Các trang ai vào cũng được (Public) */}
        <Route path="/login" element={
          accessToken ? <Navigate to={defaultRedirect()} replace /> : <LoginPage />
        } />
        <Route path="/setup" element={<SetupPage />} />

        {/* Các trang bắt buộc phải Đăng Nhập (Sẽ giải thích RequireAuth ở dưới) */}
        <Route path="/groups" element={
          <RequireAuth><GroupsListPage /></RequireAuth>
        } />
        <Route path="/group/:groupId" element={
          <RequireAuth><GroupPage /></RequireAuth>
        } />

        {/* Trang chỉ dành cho Sếp Tổng */}
        <Route path="/admin" element={
          <RequireSuperAdmin><AdminGroupsPage /></RequireSuperAdmin>
        } />

        {/* Nếu khách gõ link bậy bạ — Ném về trang mặc định */}
        <Route path="*" element={<Navigate to={defaultRedirect()} replace />} />
      </Routes>
    </BrowserRouter>
  );
}

Quy tắc Đặt Tên Đường (Số ít vs Số nhiều)

  • /groups (CÓ chữ 's') — Tượng trưng cho cái danh sách chứa nhiều nhóm.
  • /group/:groupId (KHÔNG có 's') — Tượng trưng cho bên trong một nhóm cụ thể. Tuyệt đối đừng nhầm lẫn 2 cái này nhé, dân code thường xài quy tắc này lắm!

Nếu đường link là /group/V8kJd2mNpXwQ3rY1, làm sao để lấy được cái mã V8kJd2... đó ra xài? Dùng công cụ useParams:

// Đã khai báo đường link là: /group/:groupId

function GroupPage() {
  // Lấy cái đoạn mã đó ra từ đường link
  const { groupId } = useParams<{ groupId: string }>();
  // Lúc này groupId sẽ có giá trị là "V8kJd2mNpXwQ3rY1"

  const accessToken = useAuthStore(s => s.accessToken);

  // Mang mã đó đi hỏi API xin dữ liệu
  const { data: group } = useQuery({
    queryKey: ['group', groupId],
    queryFn: () => api.groups.get(groupId!, accessToken!),
    enabled: !!groupId && !!accessToken,
  });

  return <div>{group?.name}</div>;
}

Tài Xế Riêng Tự Động Chuyển Trang (Programmatic Navigation) 🚖

Có những lúc không phải người dùng bấm link, mà là code tự động ép người dùng chuyển trang (ví dụ sau khi đăng nhập thành công). Ta dùng useNavigate:

import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { api } from '../lib/api';

function LoginPage() {
  const navigate = useNavigate(); // Gọi anh tài xế ra
  const setAuth = useAuthStore(s => s.setAuth);

  const handleLogin = async () => {
    const result = await api.auth.login({ email, password, turnstileToken });
    setAuth(result);

    // replace: true nghĩa là "Chạy đi và xóa luôn dấu vết trang cũ".
    // Nếu không có nó, người dùng bấm nút "Quay Lại (Back)" trên trình duyệt sẽ bị văng ngược về form Đăng Nhập, rất vô lý!
    navigate('/groups', { replace: true });
  };
}

function TopNav() {
  const navigate = useNavigate();
  const logout = useAuthStore(s => s.logout);

  const handleLogout = () => {
    logout();                              // Đốt thẻ nhớ
    navigate('/login', { replace: true }); // Đá văng ra ngoài cổng
  };
}

Luật rừng: Đã chơi với React, TUYỆT ĐỐI không dùng thẻ <a href> của HTML để chuyển trang nội bộ, vì nó sẽ bắt trình duyệt tải lại từ đầu. Phải dùng thẻ <Link> của React Router:

import { Link, useLocation } from 'react-router-dom';

// Dùng <Link> để bấm qua trang khác mượt mà
function GroupCard({ group }) {
  return (
    <Link to={`/group/${group.id}`} className="block p-4 hover:bg-gray-50">
      <h3>{group.name}</h3>
    </Link>
  );
}

// Dùng useLocation để biết mình đang đứng ở đâu (Ví dụ: để tô đậm màu menu đang xem)
function TopNav() {
  const location = useLocation();
  const isActive = (path: string) => location.pathname === path;

  return (
    <nav>
      {/* Đang đứng ở /groups thì chữ sẽ in đậm */}
      <Link
        to="/groups"
        className={isActive('/groups') ? 'font-bold' : ''}
      >
        Nhóm của tôi
      </Link>
    </nav>
  );
}

Lập Trạm Gác Bảo Vệ (Protected Routes) 🛡️

Thay vì trang nào cũng phải viết code kiểm tra xem "có thẻ đăng nhập chưa", ta tạo ra một cái Trạm Gác (Wrapper Component) chặn ngay từ ngoài cửa sổ khai báo Route:

  • Cách này giúp dồn việc bảo vệ vào một chỗ duy nhất (file App.tsx).
  • Không bị lỗi giao diện nhấp nháy.
  • Dễ dàng nâng cấp thêm các loại bảo vệ khác (VD: Chặn quyền Sếp).
// web/src/App.tsx
import { Navigate } from 'react-router-dom';
import { useAuthStore } from './store/authStore';

// Trạm Gác 1: Bắt buộc phải có thẻ (Đã đăng nhập)
function RequireAuth({ children }: { children: React.ReactNode }) {
  const accessToken = useAuthStore((s) => s.accessToken);
  if (!accessToken) return <Navigate to="/login" replace />; // Không có thẻ? Quay ra!
  return <>{children}</>; // Có thẻ thì mời vào (Render ruột bên trong)
}

// Trạm Gác 2: Bắt buộc phải là Sếp Tổng
function RequireSuperAdmin({ children }: { children: React.ReactNode }) {
  const { accessToken, role } = useAuthStore();
  if (!accessToken) return <Navigate to="/login" replace />;
  if (role !== 'super_admin') return <Navigate to="/groups" replace />; // Cố tình vào? Trục xuất về trang nhóm!
  return <>{children}</>;
}

// Xong rồi thì mang Trạm Gác ra bọc lại các trang
<Route path="/groups" element={
  <RequireAuth><GroupsListPage /></RequireAuth>
} />

<Route path="/admin" element={
  <RequireSuperAdmin><AdminGroupsPage /></RequireSuperAdmin>
} />

Chỉ là trò che mắt trên Frontend

Việc bọc trạm gác trên Web này chỉ để làm cho trải nghiệm mượt mà thôi. Hacker rành công nghệ hoàn toàn có thể vô hiệu hóa đoạn code này để nhìn thấy cái giao diện bên trong. Bảo mật sống còn phải nằm ở máy chủ Backend (như đã nói ở chương Hono Middleware) — có nhìn thấy nút Bấm mà gửi yêu cầu lên Backend không cho thì cũng vô dụng!


Dặn Dò Riêng Cho Cloudflare Pages (SPA Routing Config) 📝

Giả sử người dùng đang lưu bookmark (đánh dấu) cái link /group/abc123 và hôm sau mở trực tiếp link đó lên. Máy chủ Cloudflare Pages sẽ lật đật đi tìm file abc123.html để trả về. Đương nhiên là nó tìm không thấy và sẽ ném ra lỗi 404!

Mặc dù Cloudflare Pages rất thông minh có thể tự sửa lỗi này, nhưng tốt nhất là bạn nên viết một tờ giấy dặn dò (file _redirects) để mọi thứ diễn ra chắc chắn nhất:

# Tạo file này ở web/public/_redirects
/* /index.html   200

Dòng này mang ý nghĩa: "Này máy chủ, ai gõ cái link quái gở gì thì mày cứ lấy file index.html ra trả cho người ta với trạng thái 200 (Thành công) nhé. Mọi chuyện còn lại thằng React Router sẽ tự giải quyết".


So Kèo Nhanh: React Router v6 so với bản cũ v5 🥊

Bạn nào từng học bản v5 cũ rích thì xem qua để cập nhật kiến thức nhé:

Tính Năng v5 (Cũ) v6 (Mới - Dự án này xài)
Đóng gói danh sách đường Dùng thẻ <Switch> Chuyển sang <Routes>
Đẩy đi trang khác <Redirect> Tên mới: <Navigate>
Hàm gọi tài xế useHistory() Đổi thành useNavigate()
Lấy tham số đường link Lấy từ match.params Dùng hẳn hook useParams()
Độ nặng Khá nặng (~29KB) Nhẹ chỉ còn một nửa (~13KB)