Bạn cần một form “Thêm mới” hiện ra dưới dạng dialog. Cách đầu tiên ai cũng nghĩ đến:
const [isOpen, setIsOpen] = useState(false);
<Button onClick={() => setIsOpen(true)}>New URL</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<UrlForm />
</Dialog>
Đơn giản, hoạt động tốt. Nhưng thử refresh trang khi dialog đang mở — nó biến mất. Gửi link cho đồng nghiệp review form — họ không thấy gì. Nhấn Back để đóng — không được, phải click nút X.
Với một ứng dụng nhỏ thì không sao. Nhưng khi app lớn hơn, bạn sẽ nhận ra: trạng thái “dialog đang mở” thực chất là thông tin điều hướng, không phải UI state thuần túy. Và React Router có cách xử lý chính xác điều đó.
Hai loại state khác nhau
Trước khi so sánh, cần phân biệt rõ:
UI state (ephemeral) — tồn tại tạm thời trong component, không cần ghi nhớ khi refresh:
Giá trị đang gõ dở trong input “Sidebar toggle hamburger” Navigational state — phản ánh “người dùng đang ở đâu trong app”, nên tồn tại qua refresh và có thể share được:
Trang hiện tại trong pagination useState phù hợp với loại đầu. URL phù hợp với loại sau.
Nguyên tắc chủ đạo: Nếu state đó nên tồn tại qua refresh, hoặc bạn muốn người dùng có thể bookmark/share — đặt nó lên URL. Còn lại — dùng useState.
Mở Route-based Modal với React Router
Thay vì dùng state để toggle dialog, ta tạo một nested route cho nó. Dialog mở khi route match, đóng khi navigate ra ngoài.
Cấu trúc route
// main.tsx
{
path: "/list",
element: <UrlList />,
loader: UrlLoader,
children: [
{
path: "/list/new",
element: <NewUrl />,
action: NewUrlAction
}
]
}
/list/new là một route con của /list. Khi người dùng vào /list/new, cả UrlList lẫn NewUrl đều được render đồng thời.
UrlList — render Outlet để chứa Modal
// UrlList.tsx
function UrlList() {
return (
<>
<Link to="/list/new">
<Button>New URL</Button>
</Link>
<Outlet /> {/* NewUrl sẽ render ở đây khi route match */}
<Table>{/* ... */}</Table>
</>
);
}
<Outlet /> là chỗ React Router “cắm” component của route con vào. Khi URL là /list, Outlet render nothing. Khi URL là /list/new, Outlet render <NewUrl />.
NewUrl — dialog luôn open=true
// NewUrl.tsx
export default function NewUrl() {
const navigate = useNavigate();
return (
<Dialog
open={true}
onOpenChange={(open) => {
if (!open) navigate("..");
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Thêm URL</DialogTitle>
</DialogHeader>
<UrlForm />
</DialogContent>
</Dialog>
);
}
Hai điểm quan trọng ở đây:
open={true} — luôn luôn.
Component NewUrl chỉ tồn tại khi route /list/new active. Nếu nó đang render thì dialog phải mở — không cần state để track điều này.
onOpenChange navigate thay vì setState.
Khi người dùng nhấn Escape hoặc click backdrop, shadcn/ui gọi onOpenChange(false). Thay vì setIsOpen(false), ta navigate("..") — quay về /list, route con unmount, dialog biến mất.
So sánh hai cách
useState (cách thông thường)
function UrlList() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button onClick={() => setIsOpen(true)}>New URL</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<UrlForm />
</Dialog>
<Table>{/* ... */}</Table>
</>
);
}
Route-based (cách React Router)
// UrlList.tsx — không có state nào liên quan đến dialog
function UrlList() {
return (
<>
<Link to="/list/new"><Button>New URL</Button></Link>
<Outlet />
<Table>{/* ... */}</Table>
</>
);
}
// NewUrl.tsx — component độc lập, có thể test riêng
export default function NewUrl() {
const navigate = useNavigate();
return (
<Dialog open={true} onOpenChange={(open) => { if (!open) navigate(".."); }}>
<UrlForm />
</Dialog>
);
}
Lợi ích thực tế
1. Shareability & deep linking
URL /list/new có thể bookmark hoặc gửi cho người khác. Họ mở lên sẽ thấy ngay dialog — không cần click thêm bước nào.
2. Browser navigation hoạt động tự nhiên
Back đóng dialog, Forward mở lại. Người dùng không cần học thêm gì — nó hoạt động như mọi trang web khác.
3. Kết hợp mượt mà với action
Vì NewUrl là một route, nó có thể đính kèm action để xử lý form submit:
// main.tsx
{
path: "/list/new",
element: <NewUrl />,
action: NewUrlAction // xử lý POST, redirect về /list
}
Sau khi submit thành công, redirect("/list") vừa đóng dialog vừa trigger loader của UrlList chạy lại để fetch data mới — tất cả tự động.
4. Separation of concerns
UrlList không biết gì về NewUrl. NewUrl là component độc lập, có thể render và test riêng mà không cần context của UrlList.
Khi nào thì KHÔNG nên dùng route-based modal?
Pattern này không phải lúc nào cũng phù hợp:
Dialog không liên quan đến navigation — ví dụ: confirm dialog “Bạn có chắc muốn xóa không?”, tooltip, popover. Những thứ này là UI state thuần túy, useState là đúng. Không dùng React Router — nếu app không có router hoặc dùng router khác, pattern này không áp dụng được. Dialog quá đơn giản, không cần deep linking — nếu không ai cần share link hay bookmark, thêm một route mới chỉ để toggle dialog là over-engineering. Mở rộng: URL-driven UI với useSearchParams
Route-based modal là một trường hợp cụ thể của một nguyên tắc rộng hơn: bất kỳ state nào mang tính điều hướng đều có thể đặt lên URL.
Ngoài modal, các trường hợp phổ biến khác là filter, search, và pagination — những thứ mà người dùng rõ ràng muốn bookmark hoặc share. React Router cung cấp useSearchParams để đọc và ghi query string (?search=react&page=2) mà không cần tạo route mới.
Ví dụ: Search + Filter + Pagination
import { useSearchParams } from "react-router-dom";
function UrlList() {
const [searchParams, setSearchParams] = useSearchParams();
// đọc từ URL, fallback về giá trị mặc định
const search = searchParams.get("search") ?? "";
const page = Number(searchParams.get("page") ?? "1");
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchParams((prev) => {
prev.set("search", e.target.value);
prev.set("page", "1"); // reset về trang 1 khi search mới
return prev;
});
};
const handlePageChange = (newPage: number) => {
setSearchParams((prev) => {
prev.set("page", String(newPage));
return prev;
});
};
return (
<>
<input value={search} onChange={handleSearch} placeholder="Tìm URL..." />
{/* render danh sách theo search & page */}
<Pagination currentPage={page} onPageChange={handlePageChange} />
</>
);
}
setSearchParams hoạt động giống setState — gọi là URL cập nhật, component re-render với giá trị mới. Nhưng khác ở chỗ: state này nằm trên URL, không mất khi refresh.
Kết quả: URL trông như /list?search=react&page=2. Người dùng có thể bookmark bộ lọc hiện tại, gửi link cho đồng nghiệp, hoặc nhấn Back để quay về bộ lọc trước — tất cả hoạt động tự nhiên mà không cần viết thêm một dòng logic nào.
Tổng hợp: useState, route, hay searchParams?
Tóm tắt
Route-based modal không phải “cách hay hơn” trong mọi trường hợp — nó là cách đúng khi trạng thái mở/đóng của dialog mang ý nghĩa điều hướng. Câu hỏi để tự kiểm tra:
“Người dùng có nên quay lại được trạng thái này bằng URL không?”
Nếu có → đặt lên URL, dùng route.
Nếu không → useState là đủ.
Trong React Router, khi bạn cần form thêm/sửa hiện ra dưới dạng dialog, pattern nested route + <Outlet /> + navigate("..") là lựa chọn idiomatic, clean, và mở ra nhiều tính năng (deep linking, action, code splitting) mà useState không làm được.