Vẻ đẹp của TanStack Router

12 min

Đây là bản dịch từ bài viết gốc The Beauty of TanStack Router của tác giả TkDodo.

Photo by Vivek Doshi Chọn Router, có lẽ là một trong những quyết định kiến trúc quan trọng nhất mà chúng ta phải thực hiện trong một dự án. Router không chỉ là một “cục nợ” dependency trong node_modules đâu – nó là xương sống của cả ứng dụng. Nó đảm bảo người dùng lướt qua các trang mượt mà, và lý tưởng nhất là còn mang lại trải nghiệm code (Developer Experience - DX) “sướng tay” để anh em chúng ta đỡ phải “vò đầu bứt tóc” mỗi khi thêm route mới.

Năm ngoái, tôi nhận nhiệm vụ “hợp nhất” hai con app xài hai kiểu routing khác nhau. Anh em tôi đã cân nhắc kỹ lưỡng các lựa chọn. Có những điểm ở router này rất khoái, router kia lại có những cái cực hay. Nhưng chẳng có cái nào cho cảm giác “được cả đôi đường”. May sao, TanStack Router vừa ra bản v1 cách đó vài tháng, thế là chúng tôi đưa ngay vào tầm ngắm. Và, phải nói là, nó hội tụ tinh hoa của các router khác. 🤩

Bài này, tôi muốn phác thảo nhanh những tính năng “ăn tiền” khiến TanStack Router nổi bật giữa đám đông trong mắt tôi. Tôi sẽ đào sâu từng mục trong các bài sau.

Nói trước cho vuông

Giờ tôi cũng góp một tay phát triển TanStack Router rồi – nhưng hồi mới ngó nghiêng nó thì chưa đâu nhé. Tất nhiên, quen biết các maintainer cũng có lợi, nhưng tôi chỉ “nhảy vào” khi thấy có vài điểm hạn chế mà mình nghĩ là fix được.

Type Safe Routing

Như đã nói, routing là một trong những nền móng của ứng dụng web. Thế mà sao cái mảng type cho routing nó lại… tệ thế nhỉ? Kiểu như, làm cho có vậy:

  • Đây hook useParams nè, trả về Record<string, string | undefined>, tự xử phần còn lại đi.
  • Đây component <Link> nè, không reload cả trang, chuyển client-side ngon lành. Nhưng mà làm ơn tự build cái URL đi nhé – string nào cũng nhận tuốt: <Link to={`/issues/${issueId}`}>. URL đúng hay sai, chúng tôi không biết. Các bạn tự lo.

Cảm giác như “tàn dư” từ thời TypeScript còn chưa ra đời, code toàn JS chay rồi vá víu thêm type cho có, mà cũng chỉ hơn any một tẹo. Tôi nghĩ nhiều router hiện tại đúng là như vậy, và công bằng mà nói, chúng nó “sinh trước đẻ muộn” so với TypeScript.

TanStack Router thì “sống chết” với TypeScript – nó sinh ra là để dành cho TypeScript, một cặp bài trùng hoàn hảo. Dĩ nhiên, bạn vẫn xài được khi không có type, nhưng tội gì không dùng khi TypeScript support ngon thế này? Mọi tính năng đều được thiết kế với mục tiêu type-safety được suy luận (inferred type-safety) hoàn toàn. Nghĩa là không cần ép kiểu (type assertion) thủ công, không cần dấu ngoặc nhọn <> để truyền type parameter, và lỗi type thì báo rõ ràng, dễ hiểu khi bạn code “bậy”.

StrictOrFrom

Tất nhiên, chúng ta vẫn cần hook useParams. Vậy làm sao để nó “chặt” type? Chẳng phải nó phụ thuộc vào chỗ gọi nó sao? Ví dụ, khi tôi ở /issues/TSR-23, useParams có thể trả về issueId. Nhưng nếu ở /dashboards/25, thì nó lại là dashboardId (cũng là number). 🤔

Đó là lý do TanStack Router không chỉ có mỗi hook useParams đơn thuần – hook đó vẫn có, nhưng nó yêu cầu một tham số bắt buộc. Lý tưởng nhất là bạn “chỉ điểm” cho nó biết bạn đang ở route nào:

// useParams-with-from
const { issueId } = useParams({ from: '/issues/$issueId' })
// ^? const issueId: string

Cái from này là type-safe nhé – một cái union to đùng chứa tất cả các route hiện có – nên bạn không thể nhầm được. Cách này cực kỳ hợp lý khi bạn viết một component chỉ dành riêng cho route chi tiết của issue và chỉ dùng ở đó. Nếu dùng ở route khác không có issueId, nó sẽ báo lỗi invariant ngay lúc runtime.

Tôi biết bạn đang nghĩ gì – “Thế viết component tái sử dụng kiểu gì?”. Tôi muốn có component dùng được ở nhiều route khác nhau, vẫn gọi useParams rồi tùy theo param mà xử lý?

Dù tôi thấy việc viết component với useParams chỉ dùng cho một route là phổ biến hơn, TanStack Router vẫn chiều bạn tới bến. Chỉ cần thêm strict: false:

// useParams-with-strict
const params = useParams({ strict: false })
// ^? const params: {
// issueId: string | undefined,
// dashboardId: number | undefined
// }

Cách này không bao giờ lỗi runtime, và cái bạn nhận được vẫn “xịn” hơn đa số giải pháp khác tôi từng thấy. Vì router biết hết các route hiện có, nó có thể tính ra một union type của tất cả các param có thể tồn tại. Thực sự là “vi diệu”! 🤯 Và việc bạn phải chọn một trong hai cách này một cách rõ ràng giúp code base dùng TanStack Router dễ đọc hơn hẳn. Không còn phải “đoán già đoán non” xem code có chạy không, và bạn có thể refactor route một cách tự tin.

The Route Object

Chắc bạn từng thấy code ví dụ truy cập thẳng Route.useParams() mà không cần truyền gì. Còn có getRouteApi nữa, mục đích tương tự khi bạn không truy cập được trực tiếp vào Route. Về cơ bản, chúng nó làm y hệt nhau, chỉ khác là đã được “gắn cứng” (pre-bound) với một from cụ thể. Nên nếu bạn hiểu StrictOrFrom rồi, cứ coi Route.useParams() như là useParams({ from: Route.id })cho dễ hình dung.

Khỏi phải nói, component <Link> cũng “ngon” y chang. Một lần nữa, vì router biết hết các route, <Link> cũng biết route nào tồn tại, và bạn cần truyền param nào:

// type-safe-links
<Link to="/issues/$issueId" params={{ issueId: 'TSR-25' }}>
  Xem chi tiết
</Link>

Ở đây, bạn sẽ bị báo lỗi type ngay nếu không truyền issueId, truyền sai tên id, id không phải string, hoặc cố tình trỏ tới một URL không tồn tại. Quá đẹp! 😍

Quản lý State của Search Param

“Use the platform” (Tận dụng nền tảng) là một nguyên tắc hay, và chẳng có gì “nền tảng” hơn thanh địa chỉ trình duyệt. Người dùng thấy nó. Họ copy-paste, chia sẻ được. Nó còn có sẵn undo/redo với nút back/forward.

Nhưng, một lần nữa, URLSearchParams lại là một mớ hỗn độn về type. Đúng là chúng ta không biết trong đó có gì vì người dùng có thể sửa trực tiếp. Ai cũng đồng ý là input của người dùng không đáng tin và cần được validate (xác thực). Vậy, nếu search param gắn với một route, và chúng ta phải validate vì không tin được, và không thể có type-safety nếu không validate – thì tại sao router không validate luôn cho rồi?

Tôi cũng không hiểu nổi, bởi vì đó chính xác là những gì TanStack Router làm. Bạn có thể lấy search param bằng useSearch – nó cũng dùng nguyên lý StrictOrFrom như path param – và validate param ngay trên định nghĩa route:

// search-param-validation
export const Route = createFileRoute('/issues')({
  validateSearch: issuesSchema,
})

TanStack Router hỗ trợ schema chuẩn, nên bạn có thể viết issuesSchema bằng bất kỳ thư viện tương thích nào. Tự viết hàm validate cũng được, vì nó chỉ là một function từ Record<string, unknown> ra cái type bạn muốn. Gần đây tôi khá nghiện arktype:

// issuesSchema-with-arktype
import { type } from 'arktype'

const issuesSchema = type({
  page: 'number > 0 = 1', // page là số, lớn hơn 0, mặc định là 1
  filter: 'string = ""', // filter là string, mặc định là rỗng
})

export const Route = createFileRoute('/issues')({
  validateSearch: issuesSchema,
})

Thế là xong! Giờ gọi useSearch({ from: '/issues' }) sẽ được validate & có type đầy đủ, và khi navigate tới /issues (bằng useNavigate hay <Link>) thì option search cũng có type luôn. Router sẽ lo luôn việc parse và serialize giá trị, kể cả object hay array lồng nhau. Để tránh re-render không cần thiết, kết quả mặc định dùng structural sharing, không tạo object mới mỗi lần update.

Fine-grained Subscriptions (Theo dõi State “chi li”)

Nói về re-render thừa thãi, tôi thấy ít ai để ý việc một thay đổi trên URL thường kéo theo re-render tất cả những ai “quan tâm” đến nó. Chuyện này thường không sao nếu bạn chỉ chuyển giữa hai trang. Nhưng nếu có nhiều route lồng nhau, mỗi route lại muốn ghi vào URL, thì việc re-render cả trang có thể cực kỳ lãng phí và làm giảm trải nghiệm người dùng

Có lần, chúng tôi gặp một cái table trên route có cả đống filter lưu trên URL. Click vào một dòng thì mở ra một sub-route dạng dialog. Mở dialog lên là cái table lại re-render, vì bên trong có dùng useParams. Mà table đó lại xài Infinite Query, nên nếu người dùng đã load cả núi data, mở dialog sẽ thấy giật lag rõ rệt.

Đây chính là đất dụng võ của các “trùm quản lý state toàn cục” như Redux hay Zustand – chúng cho phép component chỉ subscribe vào những phần state mà nó thực sự cần, để chỉ re-render khi “crush” của nó thay đổi. Vậy tại sao URL lại không được như thế?

Bạn có thể tự mình vật lộn với “Memoization: Cuộc chiến không hồi kết (The Uphill Battle of Memoization)”, hoặc đơn giản là dùng TanStack Router, nó cung cấp sẵn selector:

// fine-grained-selectors
const page = useSearch({
  from: '/issues',
  select: search => search.page, // Chỉ lấy 'page' thôi
})

Nếu bạn từng xài state manager nào có selector (hoặc TanStack Query), thì nhìn quen ngay. Selector giúp bạn “chỉ mặt đặt tên” phần state muốn theo dõi, và nó có mặt ở nhiều hook như useParams, useSearch, useLoaderDatauseRouterState. Với tôi, đây là một trong những tính năng đỉnh nhất của Router này, tạo nên sự khác biệt. 🙌

File-Based Routing (Routing theo cấu trúc file/thư mục)

Declarative Routing (Routing kiểu khai báo) không hiệu quả lắm đâu. Ý tưởng cứ để app render các component <Route> nghe thì hay, nhưng nhanh chóng bộc lộ vấn đề: <Route> nằm rải rác khắp các component lồng nhau đúng là “ác mộng” khi bảo trì. Đã bao lần tôi thấy kiểu này:

// declarative-routing
<Route path="settings" element={<Settings />} />

trong codebase, rồi mới ngớ người ra là path trên URL không phải /settings. Có khi nó là /organization/settings hoặc /user/$id/settings – bạn không tài nào biết nếu không mò ngược lên cây component. Quá tệ!

OK, thế thì đừng tách ra nhiều file nữa? Ừ thì, bạn sẽ có một cục như này:

// one-route-tree
export function App() {
  return (
    <Routes>
      <Route path="organization">
        <Route index element={<Organization />} />
        <Route path="settings" element={<Settings />} />
      </Route>
      <Route path="user">...</Route>
    </Routes>
  )
}

Cái này dễ thành một cây Routes khổng lồ, lồng ghép phức tạp. Cũng được thôi, nhưng có một vấn đề khác: Để có type-safety, các route phải được định nghĩa trước, mà điều này lại xung đột cơ bản với declarative routing.

Đó là lúc Code-Based Routing (Routing bằng code) lên ngôi, một bước tiến hóa tự nhiên: Nếu đằng nào cũng muốn gom hết định nghĩa route vào một chỗ, sao không tách nó ra khỏi React component luôn? Như vậy, chúng ta có thể lấy thông tin type về các route hiện có ngay từ đầu. createBrowserRoute của React Router làm vậy, và createRouter của TanStack Router cũng thế.

File-Based Routing chỉ đơn giản là lấy cái config route bạn hay viết tay, rồi sắp xếp chúng theo cấu trúc thư mục. Tôi biết nhiều người không khoái kiểu này, nhưng cá nhân tôi lại thấy khá ổn. Tôi thấy đây là cách nhanh nhất để bắt đầu, cũng như nhanh nhất để từ một URL trong bug report mò ra được đoạn code render cái màn hình đó. Đây cũng là cách tốt nhất để có code splitting tự động cho route. Nếu không thích cây thư mục sâu, bạn có thể dùng flat routes (route phẳng), hoặc thậm chí tạo Virtual File Routes nếu muốn tùy biến vị trí file route.

Nói chung, TanStack Router hỗ trợ cả code-based lẫn file-based, vì suy cho cùng, tất cả đều là code-based mà thôi. 🤓

Tích hợp Sẵn Suspense

Mối quan hệ của tôi với React Suspense nó hơi “trên tình bạn dưới tình yêu”, nhưng tôi mê cái cách TanStack Router tích hợp sẵn nó. Mặc định, mỗi route được bọc trong một <Suspense> boundary và một <ErrorBoundary>, nên tôi cứ thế mà xài useSuspenseQuery trong component của route là đảm bảo có data:

// "useSuspenseQuery"
export const Route = createFileRoute('/issues/$issueId')({
  loader: ({ context: { queryClient }, params: { issueId } }) => {
    // Không cần await, cứ prefetch là được
    void queryClient.prefetchQuery(issueQuery(issueId))
  },
  component: Issues,
})

function Issues() {
  const { issueId } = Route.useParams()
  const { data } = useSuspenseQuery(issueQuery(issueId)) // Data ở đây chắc chắn có, type là Issue
  // ^? const data: Issue
}

Nghĩa là tôi có thể tập trung hoàn toàn vào việc hiển thị “happy path” (luồng chạy đúng), không cần lo data có thể undefined – kể cả khi tôi không await gì trong loader. 🎉 Quá đã!

React Transitions

Dù TanStack Router hỗ trợ Suspense ngon lành, nhưng với React Transitions thì chưa được “đẹp” lắm. Các navigation có được bọc trong startTransition, nhưng nó lại lưu state bên ngoài React và dùng useSyncExternalStore để bật fine-grained subscriptions. Việc này có cái giá của nó, vì useSyncExternalStore không chơi được với các tính năng concurrent. Anh em đang hóng concurrent stores để giải quyết vấn đề này.

Còn nhiều thứ “hay ho” nữa

Tôi còn chưa kịp kể về Route Context, Nested Routes (Route lồng nhau), Query Integration, Search Middleware, cách setup trong monorepo hay lớp SSR tùy chọn TanStack Start… nhưng tin tôi đi: Mấy cái đó đảm bảo sẽ làm bạn “mắt chữ A mồm chữ O” đấy.

Vấn đề lớn nhất của TanStack Router là một khi đã “nếm thử”, bạn sẽ thấy khó mà quay lại với các giải pháp routing khác – bạn sẽ bị “chiều hư” bởi DX và type-safety của nó. Kết hợp với React Query, bộ đôi này đã thay đổi hoàn toàn năng suất làm việc của tôi, và tôi rất nóng lòng muốn chia sẻ thêm về chúng.

Xin ngả mũ thán phục Tanner, Manuel, SeanChristopher – họ thực sự đã tạo ra một “tuyệt tác”. Sự tỉ mỉ, tập trung vào DX và type-safety của họ thể hiện trong từng ngóc ngách của router này.