Skip to content

useState vs URL-driven UI

Khi nào nên để router làm việc thay state?
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:
Tooltip đang hover
Giá trị đang gõ dở trong input
Menu dropdown đang mở
“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:
Dialog/modal đang mở
Tab đang active
Trang hiện tại trong pagination
Bộ lọc đang áp dụng
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>
);
}
useState
Route-based
Refresh trang khi dialog mở
Dialog đóng lại
Dialog vẫn mở
Chia sẻ link
Không thể
Được (/list/new)
Nút Back trên trình duyệt
Không làm gì
Đóng dialog
Tách biệt code
Dialog nằm trong UrlList
NewUrl là component độc lập
Lazy load dialog
Khó
Dễ (route-level code splitting)
Kết hợp với action
Cần thêm setup
Tự nhiên (route có sẵn action)
There are no rows in this table

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 actionNewUrl 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 concernsUrlList 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?

Loại state
Ví dụ
Nên dùng
Ephemeral / local
Hover, dropdown đang mở, giá trị đang gõ
useState
Navigational (có URL riêng)
Modal thêm/sửa, trang detail
Nested route
Navigational (cùng trang)
Search, filter, sort, pagination
useSearchParams
There are no rows in this table

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.
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.