Type Safe React Router - It’s Better Than React Router
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.

website

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.

home

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

validation

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.

devtools

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.

posts list

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.

And actually, if you want to improve your React knowledge and prepare for interviews, I highly recommend you check out my course React Interview Questions.

📚 Source code of what we've done