Type Safe React Router - It’s Better Than React Router
In this post, you will learn about a library called Tanstack Router, which is a type-safe React router.
If you are using React, you have likely used react-router
because there is no possibility to create routes inside a React application without additional libraries.
react-router
is the most popular library for creating routes in your application.
What problem do we have? It supports TypeScript (kind of partially), so you get some typings, but it is not like you have a fully safe application where you are sure that you can only use routes that you defined and nothing else.
This is precisely where we have an alternative, which is called tanstack-router
. Here is the official website, and on Tanstack, you can find many different libraries, all written with a high level of quality TypeScript code.
The main benefit of this library is that it is fully covered with TypeScript and is type-safe.
If you define that you have a route /home
, then you can't use the route /foo
anywhere in your application, as TypeScript won't allow it. So you get everything similar to normal React Router, such as reading parameters, query parameters, changing your routes, nested routes, but additionally, you get amazing TypeScript support.
Project Installation
This is why let's set up an application with Tanstack Router.
npm i @tanstack/router @tanstack/router-devtools
We need devtools if we want to debug our routes. It's not mandatory, but it's nice to have.
When we configure our application with Tanstack, we do it a bit differently.
// src/main.tsx
import ReactDOM from "react-dom/client";
import { AppRouter } from "./routes/Router";
ReactDOM.createRoot(document.getElementById("root")!).render(<AppRouter />);
As you can see, we don't render an App
component as we typically do, but an AppRouter
as our default component.
// src/routes/Router.tsx
import { Router, RouterProvider } from "@tanstack/react-router";
import { rootRoute } from "./root";
const routeTree = rootRoute.addChildren([]);
const router = new Router({ routeTree, defaultPreload: "intent" });
export const AppRouter = () => {
return <RouterProvider router={router} />;
};
Here is the core part of our application, defining a router. Our AppRouter
acts as a RouterProvider
, which accepts a router
. To create a router
, we call new Router
with a routeTree
(a list of our routes) and an option defaultPreload
. We set it to intent
, which means that it will try to preload the page when we hover over the link to the page.
It's a nice performance optimization, and your application feels much faster.
Our routeTree
starts with rootRouter
, which is our layout, and we add all children routes inside. Now it's time to create our rootRoute
.
// src/routes/root.tsx
import { Link, Outlet, RootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
export const rootRoute = new RootRoute({
component: () => (
<div>
<div>RootRoute</div>
<div>
<Link to="/">Home</Link>
<Link to="/posts">Posts</Link>
</div>
<Outlet />
<TanStackRouterDevtools />
</div>
),
});
To create a root route in Tanstack, we use new RootRoute
and define a component
property inside. Within this component, we render an Outlet
, which will contain our current route, along with devtools for debugging.
Now let's create a child route called Home
, which we want to render by default.
// src/routes/home.tsx
import { Route } from "@tanstack/react-router";
import { rootRoute } from "./root";
export const homeRoute = new Route({
getParentRoute: () => rootRoute,
path: "/",
component: () => <div>Homepage</div>,
});
To create a normal route, we use newRoute
. Inside this route, we must provide a root Route
, a path for this route, and the component that we want to render.
Now, we must register this route in the list.
// src/routes/Router.tsx
...
import { homeRoute } from "./home";
const routeTree = rootRoute.addChildren([homeRoute]);
...
We've added our home route as a child of the root route, and we are done with configuring our routes.
As you can see, we rendered our root route, links to Home and Posts, and a Homepage route because we are on our homepage.
The Magic of Tanstack
Sure, let's dive into the magic of Tanstack and explore why choosing Tanstack Router over React Router makes sense.
// src/routes/Router.tsx
...
const router = new Router({ routeTree, defaultPreload: "intent" });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
...
The declare
section provides all the types of routes that we defined to Tanstack Router. This brings several benefits
You can see in our rootRoute
that we are getting errors now. It validates all our routes and shows that we don't have a /posts
route registered. Currently, only the /
route is being registered.
Now we realize that we need to create a new /posts
route in order to fix the problem.
// src/routes/posts.tsx
import { Link, Route } from "@tanstack/react-router";
import { rootRoute } from "./root";
export const postsRoute = new Route({
getParentRoute: () => rootRoute,
path: "/posts",
component: () => {
return (
<div>
<h1>Posts</h1>
</div>
);
},
});
The idea remains the same: we create a new route with the path /posts
and render some markup. However, we must not forget to add this route as a child route.
// src/routes/Router.tsx
...
import { homeRoute } from "./home";
import { postsRoute } from "./posts";
const routeTree = rootRoute.addChildren([homeRoute, postsRoute]);
...
Now, as you can see, the error is no longer present.
Additionally, at the bottom of the screen, we can open Tanstack Devtools and view all the properties that we have now, which can help us in debugging later.
Fetching Data
Now, I'd like to demonstrate how we can fetch data with Tanstack. My intention is to fetch some data from an API and render it inside the Posts
component. The simplest approach to achieve this is by fetching the data before we access our component. In doing so, we can ensure that the data is available for us when needed.
// src/routes/posts.tsx
import { Link, Route } from "@tanstack/react-router";
import { rootRoute } from "./root";
import axios from "axios";
export type Post = {
slug: string;
title: string;
body: string;
};
const fetchPosts = async () => {
const response = await axios.get<{ articles: Post[]; articlesCount: number }>(
"https://api.realworld.io/api/articles"
);
return response.data;
};
export const postsRoute = new Route({
getParentRoute: () => rootRoute,
path: "/posts",
loader: fetchPosts,
component: () => {
const data = postsRoute.useLoaderData();
return (
<div>
<h1>Posts</h1>
<div>
{data.articles.map((post, index) => (
<div key={index}>
<Link to="/posts/$postSlug" params={{ postSlug: post.slug }}>
{post.title}
</Link>
</div>
))}
</div>
</div>
);
},
pendingComponent: () => <div>Posts loading</div>,
errorComponent: () => <div>Posts error</div>,
});
Firstly, we've defined a Post
type with attributes such as slug, title, and body. Then, we've created a fetchPosts
function, which returns a promise and retrieves a list of posts from a public API.
Now, we can provide the promise fetchPosts
to the loader
property. Tanstack will automatically resolve this promise, making the data available for us in the component.
We've used /posts/$postSlug
to define a dynamic route for each post. Currently, it appears in red as we haven't defined such a route yet, but that's okay.
To access this data in the component, we've used postsRoute.useLoaderData()
and rendered it on the screen. Additionally, we've defined a pendingComponent
to be rendered while making the API call, and an errorComponent
to be rendered if the API throws an error.
As you can see, we've successfully rendered a list of posts on the screen by making an API call.
Defining a Single Post
Yes, indeed, we've used a Link to navigate to a specific post, and it's currently highlighted in red because we haven't defined a page for a single post yet. Let's proceed to define that.
// src/routes/post.tsx
import { Route } from "@tanstack/react-router";
import { rootRoute } from "./root";
import axios from "axios";
import { Post } from "./posts";
const fetchPost = async (postSlug: string) => {
const response = await axios.get<{ article: Post }>(
`https://api.realworld.io/api/articles/${postSlug}`
);
return response.data.article;
};
export const postRoute = new Route({
getParentRoute: () => rootRoute,
path: "/posts/$postSlug",
loader: ({ params }) => fetchPost(params.postSlug),
component: () => {
const post = postRoute.useLoaderData();
return (
<div>
<h1>{post.title}</h1>
</div>
);
},
});
Here is our new route for a single post. The concept remains the same: we use the loader
to fetch data from the API. The only difference is that inside the params
, we have a postSlug
that we need to fetch. After fetching the data, we render our post on the screen.
We also shouldn't forget to register this route in our list.
...
import { postRoute } from "./post";
const routeTree = rootRoute.addChildren([homeRoute, postsRoute, postRoute]);
As you can see, we don't have any errors in our components anymore.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done