Tanstack Router For React - A Complete Guide

OpenReplay Tech Blog - Jun 19 - - Dev Community

by Wisdom Ekpotu

Tanstack Router provides an easy, safe way to define routes for your React web site, and is worth a look, as this article shows.

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay β€” an open-source session replay suite for developers. It can be self-hosted in minutes, giving you complete control over your customer data.

OpenReplay

Happy debugging! Try using OpenReplay today.


Tanstack Router is a modern and scalable routing solution for React, created by Tanner Linsey(creator of react-query). Its core objective centers around type-safety and developer productivity.

The core philosophy behind TanStack Router is simply the full utilization of Typescript for web routing. Developers should be able to write type-safe routes, actions, and loaders, which will result in fewer runtime errors. This provides a cohesive environment where routes are well-defined type-safe contracts instead of just navigation pathways in an application.

On the contrary, React Router is built on the philosophy of simplicity and flexibility. It aims to make routing implementation in React applications simple. Unlike TanStack Router, React Router takes an unopinionated and incremental adoption approach, allowing developers to start simple and gradually add more advanced routing techniques as the need arises.

The following table from the TanStack Router documentation compares TanStack Router and React Router:

Feature/Capability Key:

  • βœ… 1st-class, built-in, and ready to use with no added configuration or code
  • πŸ”΅ Supported via add-on package
  • 🟑 Partial Support
  • πŸ”Ά Possible, but requires custom code/implementation/casting
  • πŸ›‘ Not officially supported
TanStack Router React Router DOM (Website) Next.JS (Website)
History, Memory & Hash Routers βœ… βœ… πŸ›‘
Nested / Layout Routes βœ… βœ… βœ…
Suspense-like Route Transitions βœ… βœ… βœ…
Typesafe Routes βœ… πŸ›‘ 🟑
Code-based Routes βœ… βœ… πŸ›‘
File-based Routes βœ… βœ… βœ…
Router Loaders βœ… βœ… βœ…
SWR Loader Caching βœ… πŸ›‘ βœ…
Route Prefetching βœ… βœ… βœ…
Auto Route Prefetching βœ… πŸ”΅ (via Remix) βœ…
Route Prefetching Delay βœ… πŸ”Ά πŸ›‘
Path Params βœ… βœ… βœ…
Typesafe Path Params βœ… πŸ›‘ πŸ›‘
Path Param Validation βœ… πŸ›‘ πŸ›‘
Custom Path Param Parsing/Serialization βœ… πŸ›‘ πŸ›‘
Ranked Routes βœ… βœ… βœ…
Active Link Customization βœ… βœ… βœ…
Optimistic UI βœ… βœ… πŸ”Ά
Typesafe Absolute + Relative Navigation βœ… πŸ›‘ πŸ›‘
Route Mount/Transition/Unmount Events βœ… πŸ›‘ πŸ›‘
Devtools βœ… πŸ›‘ πŸ›‘
Basic Search Params βœ… βœ… βœ…
Search Param Hooks βœ… βœ… βœ…
<Link/>/useNavigate Search Param API βœ… 🟑 (search-string only via the to/search options) 🟑 (search-string only via the to/search options)
JSON Search Params βœ… πŸ”Ά πŸ”Ά
TypeSafe Search Params βœ… πŸ›‘ πŸ›‘
Search Param Schema Validation βœ… πŸ›‘ πŸ›‘
Search Param Immutability + Structural Sharing βœ… πŸ”Ά πŸ›‘
Custom Search Param parsing/serialization βœ… πŸ”Ά πŸ›‘
Search Param Middleware βœ… πŸ›‘ πŸ›‘
Suspense Route Elements βœ… βœ… βœ…
Route Error Elements βœ… βœ… βœ…
Route Pending Elements βœ… βœ… βœ…
<Block>/useBlocker βœ… πŸ”Ά ❓
SSR βœ… βœ… βœ…
Streaming SSR βœ… βœ… βœ…
Deferred Primitives βœ… βœ… βœ…
Navigation Scroll Restoration βœ… βœ… ❓
Loader Caching (SWR + Invalidation) πŸ”Ά (TanStack Query is recommended) πŸ›‘ βœ…
Actions πŸ”Ά (TanStack Query is recommended) βœ… βœ…
<Form> API πŸ›‘ βœ… βœ…
Full-Stack APIs πŸ›‘ βœ… βœ…
Comparison Table
credit: TanStack Router Documentation

Build a Single Page Application with React and TanStack Router

In this section, we will build a single-page application in React that retrieves data from an API and runs in the browser. Routing will be done using TanStack Router, and Tailwind-CSS will be used for styling. The application you'll build will show information about popular movies via the TMDb API.

Building this project from scratch will help us understand how to use the TanStack Router from a hands-on perspective, which is crucial when learning a new technology.

Prerequisite

The complete code for this project can be found on GitHub.

Project Demo

bandicam2024-04-2013-25-27-366-ezgif.com-optimize

Creating a Vite App and Installing Dependencies

We will use vite to create our React app.

npm create vite@latest movie-app --template react-ts

npm install
Enter fullscreen mode Exit fullscreen mode

The preceding command allows you to scaffold a react project with Typescript using Vite. We use Typescript because our routing will be done via Tanstack Router, which is 100% type-safe.

Next, install the following dependencies; they will be utilized for the project.

npm install -D zod tailwindcss postcss autoprefixer

npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

In the preceding command, you installed zod, tailwindcss and its peer dependencies. The npx tailwindcss init -p command generates the tailwind.config.js and postcss.config.js files, which will be used to configure tailwind. Also, the zod library will validate and infer Typescript type(s) for routes.

After running the above commands, the following package.json will be created for the project.

{
 "name": "movie-app",
 "private": true,
 "version": "0.0.0",
 "type": "module",
 "scripts": {
 "dev": "vite",
 "build": "tsc && vite build",
 "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
 "preview": "vite preview"
 },
 "dependencies": {
 "react": "^18.2.0",
 "react-dom": "^18.2.0"
 },
 "devDependencies": {
 "@types/react": "^18.2.66",
 "@types/react-dom": "^18.2.22",
 "@typescript-eslint/eslint-plugin": "^7.2.0",
 "@typescript-eslint/parser": "^7.2.0",
 "@vitejs/plugin-react": "^4.2.1",
 "autoprefixer": "^10.4.19",
 "eslint": "^8.57.0",
 "eslint-plugin-react-hooks": "^4.6.0",
 "eslint-plugin-react-refresh": "^0.4.6",
 "postcss": "^8.4.38",
 "tailwindcss": "^3.4.3",
 "typescript": "^5.2.2",
 "vite": "^5.2.0",
 "zod": "^3.22.4"
 }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's configure tailwind via the tailwind.config.js and index.css files, respectively.

Enter the following code:

tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
 content: [
 "./index.html",
 "./src/**/*.{js,ts,jsx,tsx}",
 ],
 theme: {
 extend: {},
 },
 plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

src\index.css:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Tanstack Router Setup

The development environment for our project is ready. In this section, we will install and configure TanStack Router, which is the main focus of this project. Let's start by installing the TanStack Router library.

Enter the following commands:

npm install @tanstack/react-router

npm install --save-dev @tanstack/react-router-vite-plugin
Enter fullscreen mode Exit fullscreen mode

The @tanstack/react-router-vite-plugin will regenerate the routes whenever our application compiles.

Next, we set up Vite to use the plugin we just installed.

Enter the following code in vite.config.ts:

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from "@tanstack/router-vite-plugin";

// https://vitejs.dev/config/
export default defineConfig({
 plugins: [react(), TanStackRouterVite()],
});
Enter fullscreen mode Exit fullscreen mode

The Routes Directory

All route implementations for our application are carried out in the' routes' directory.

In the source[src] directory, create a new subdirectory called routes. This directory will contain all the routes of our application. Tanstack Router will infer routes based on its folder structure via filenames.

Next, inside the routes directory create the files __root.tsx and index.tsx respectively.

πŸ“¦ movie-app
┣ πŸ“‚ src
┃ ┣ πŸ“‚ routes
 ┃ ┣ πŸ“„ __root.tsx
 ┃ ┣ πŸ“„ index.tsx
Enter fullscreen mode Exit fullscreen mode

Next, run the following command:

npm run dev
Enter fullscreen mode Exit fullscreen mode

The preceding command will generate a routeTree for our application located in routeTree.gen.ts inside the src directory.

// src\routeTree.gen.ts
import { Route as rootRoute } from "./routes/__root";
import { Route as IndexImport } from "./routes/index";

// Create/Update Routes
const IndexRoute = IndexImport.update({
 path: "/",
 getParentRoute: () => rootRoute,
} as any);

// Populate the FileRoutesByPath interface
declare module "@tanstack/react-router" {
 interface FileRoutesByPath {
 "/": {
 preLoaderRoute: typeof IndexImport;
 parentRoute: typeof rootRoute;
 };
 }
}

// Create and export the route tree
export const routeTree = rootRoute.addChildren([IndexRoute]);
Enter fullscreen mode Exit fullscreen mode

Router Instance

The router instance is the mechanism that connects TanStack Router to our React applicationβ€”just like <BrowserRouter> from React Router.

Enter the following code in App.tsx:

// src\App.tsx
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

const router = createRouter({ routeTree });

declare module "@tanstack/react-router" {
 interface Register {
 router: typeof router;
 }
}

function App() {
 return <RouterProvider router={router} />;
}

export default App;
Enter fullscreen mode Exit fullscreen mode

The preceding code creates a router instance via the createRouter function from TanStack Router, which ensures all declared routes are 100% type-safe.

Layout Route

The layout route is the parent container of all routes in our application.

Enter the following code in __root.tsx:

// src\routes\__root.tsx
import { createRootRoute, Outlet, Link } from '@tanstack/react-router';

export const Route = createRootRoute({
 component: LayoutComponent,
});

function LayoutComponent() {
 return (
 <html lang='en'>
 <head>
 <meta charSet='UTF-8' />
 <meta name='viewport' content='width=device-width, initial-scale=1.0' />
 <title>Movie-App</title>
 </head>
 <body className='bg-black max-w-4xl mx-auto text-white px-5 md:px-0'>
 <header className='flex justify-between items-center p-4 bg-[#780909] text-white rounded-b-2xl shadow-xl shadow-[#df0707] mb-6'>
 <h1 className='text-2xl flex'>
 <Link
 to='/'
 search={{ page: 1 }}
 activeProps={{
 className: 'font-bold hello',
 }}
 activeOptions={{
 includeSearch: false,
 }}
 >
 Movies🍿
 </Link>
 <div className='mx-5'>|</div>
 <Link
 to='/search'
 search={{ q: '' }}
 activeProps={{
 className: 'font-bold',
 }}
 >
 Search
 </Link>
 </h1>
 <div id='favorites-count'>{/* <FavoritesCount /> */}</div>
 </header>
 <Outlet /> {/* Start rendering router matches */}
 </body>
 </html>
 );
}
Enter fullscreen mode Exit fullscreen mode

The preceding code does the following:

  • Creates a root route component(via createRootRoute) that will be displayed on every application page.
  • Implements basic navigation for our application via the Link component for TanStack Router.
  • Renders other paths that the router will match via the <Outlet/> component from TanStack Router.
  • Finally, applies styles in markup via tailwind classes.

Next, if the npm run dev command is still running, you should get the following output in your browser.

Browser Output:

screenshot

Building the Movies Index-Page

In this section, we will build the index page of our app, where all the movies retrieved from an API will be displayed in a paginated view.

N/B: We'll use the TMDb REST API, which provides information about various popular TV shows and movies. Get an API key.

When you ran the npm run dev command after setting up TanStack Router, along with the routeTree being generated, placeholder code was also generated for each file in the routes directory. So, your index.tsx page should look like this:

// src\routes\index.tsx
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/')({
 component: () => <div>Hello/!</div>,
});
Enter fullscreen mode Exit fullscreen mode

In the preceding code, we have a basic route implementation with TanStack Router. Unlike other file-based routers where components are exported, we export the file route instead. We create the file route via createFileRoute function, which accepts a single argument of type string that represents the path of the file that the route will be inferred from. Finally, we pass in a component that will be rendered when we hit the route path.

Pagination

Let's implement the pagination feature to allow us to view data retrieved from the API in paginated segments.

First, we will create the pagination component.
In the src directory create a subdirectory called components and then create a paging.tsx file.

Enter the following code in paging.tsx

import React from 'react';
import { Link } from '@tanstack/react-router';

export default function Paging({
 pages,
 Route,
 page,
}: {
 pages: number;
 Route: any;
 page: number;
}) {
 return (
 <div className='flex gap-1 text-xl font-bold justify-end'>
 {new Array(pages).fill(0).map((_, i) =>
 page === i + 1 ? (
 <div className='px-4 py-2 border border-red-300 rounded bg-[#0b0000] text-white'>
 {i + 1}
 </div>
 ) : (
 <Link
 key={i}
 from={Route.id}
 search={{
 page: i + 1,
 }}
 className='px-4 py-2 border border-red-300 rounded hover:bg-[#a33d3da1]'
 >
 {i + 1}
 </Link>
 )
 )}
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code, we have a basic react presentational component. The pagination logic and conditional rendering based on the logic are in the' return' block of the component.

The pagination logic is basically:

  • A new array is created with its length set to the number of pages that will be paginated via the pages prop.
  • Each index in the array is filled with0β€” array.fill(0)
  • The looped through via the map() function, where we check if the current page we have in our search param(via page prop) equals the index of the current array item.
  • Next, a presentational component is shown depending on the result of the preceding condition.
  • The Link component is used to navigate to a new page if the conditional is trueβ€”i.e, the search param(page) equals the current index(i). Check the docs for prop options(from, search) used in the Link component.
  • Finally, markup is styled with tailwind.

Next, we will build the index route, where movies will be retrieved from the API and paginated.

Enter the following code in index.tsx:

// src\routes\index.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { z } from "zod";
import Paging from "../components/Paging";

export const Route = createFileRoute("/")({
 component: IndexComponent,
 validateSearch: z.object({
 page: z.number().catch(1),
 }),
});

function IndexComponent() {
 const pages = 4;
 const { page } = Route.useSearch();
 return (
 <div>
 <div className="flex justify-end pr-5 py-5">
 <Paging page={page} pages={pages} Route={Route} />
 </div>
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

The preceding code does the following:

  • The zod library is used to manage and type our search parameters.
  • The validateSearch option in createFileRouteis used to validate our search parameters from the URL via the zod library. In this case page must be number with a default value of 1.
  • The IndexComponent is a component that will be rendered when the route path is matched.
  • In IndexComponent, useSearch hook is used to access the current value of the page search parameter.
  • The pages variable represents the number of pages for pagination.
  • Required props are passed to the Paging component.

Browser output:

pagination

Retrieving Data

We will be retrieving the data for our application from the TMDb API which will require an API key, get it here.

Next, create an api.ts file in the src directory and enter the following code:

// get all Movies
export async function getMovies(page: number = 1) {
 const response = await fetch(
 `https://api.themoviedb.org/3/movie/popular?include_adult=false&language=en-US&page=${encodeURIComponent(page)}&api_key=${API_KEY}`
 )
 .then((r) => r.json())
 .then((r) => ({
 pages: 4,
 movies: r.results,
 }));
 return response;
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code, the exported getMovies() function is used to fetch data from the API. It accepts a page parameter from the URL search parameters. Finally, it returns an object, where the pages property represents the number of pages, and movies is the data gotten from the API to be displayed on the UI.

Now, in index.tsx, enter the following code:

// src\routes\index.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import Paging from "../components/Paging";
import { getMovies } from "../api";
import { z } from "zod";

export const Route = createFileRoute("/")({
 component: IndexComponent,
 validateSearch: z.object({
 page: z.number().catch(1),
 }),
 loaderDeps: ({ search: { page } }) => ({ page }),
 loader: ({ deps: { page } }) => getMovies(page),
});

function IndexComponent() {
 const { page } = Route.useSearch();
 const { movies, pages } = Route.useLoaderData();

 return (
 <div>
 <div className="flex justify-end pr-5 py-5">
 <Paging page={page} pages={pages} Route={Route} />
 </div>
 <div>{JSON.stringify(movies, null, 2)}</div>
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code, note the following:

  • The getMovies() is imported and used. It gives us access to data from the API.
  • The / route support pagination via the search param page. For this data to be stored in a cache, it has to be accessed via the loaderDeps function.
  • The loader gets the page search param stored in cache and uses it to get data from the API via the getMovies() function.
  • Route.useLoaderData() gives us access to the data loaded in the loader.
  • Finally, JSON.stringify() is used to show the data on the UI.

Browser Output:

screenshot

Now, let's create a presentational component to make our UI more presentable:

In the components directory create MovieCards.tsx file, and add the following code:

import { Link } from '@tanstack/react-router';

export default function IndexComponent({ movies }: { movies: any[] }) {
 return (
 <div className='grid grid-cols-1 md:grid-cols-2'>
 {movies.map((m, i) => (
 <Link
 to='/movies/$movieId'
 params={{
 movieId: m.id,
 }}
 className='flex m-2'
 key={m.id || i}
 >
 <img
 src={`https://image.tmdb.org/t/p/w500${m.poster_path}`}
 className='rounded-tl-lg rounded-bl-lg aspect-w-5 aspect-h-7 w-1/4'
 />
 <div className='w-3/4 flex flex-col'>
 <div className='font-bold text-xl px-4 bg-[#ba0c0c] text-white py-2 rounded-tr-md'>
 {m.original_title}
 </div>
 <div className='border-red-900 border-b-2 border-r-2 rounded-br-lg flex-grow pt-3'>
 <div className='italic line-clamp-2 px-4'>{m.overview}</div>
 <div className='flex justify-between px-4 pt-3 items-center'>
 {/* <FavoriteButton movieId={m.id} /> */}
 <div>{m.vote_average.toFixed(1)} out of 10</div>
 </div>
 </div>
 </div>
 </Link>
 ))}
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

N/B: The path /movies/$movieId in the to property in Link is not yet defined, so you will see an error. We will fix it in the next section.

Next, we will import this component into index.tsx, where it will replace JSON.stringify(), which is currently being used.

.....
import MovieCards from "../components/MovieCards";

....
function IndexComponent() {
 const { page } = Route.useSearch();
 const { movies, pages } = Route.useLoaderData();

 return (
 <div>
 <div className="flex justify-end pr-5 py-5">
 <Paging page={page} pages={pages} Route={Route} />
 </div>
 <MovieCards movies={movies} />
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

Browser Output:

screenshot

Our app's UI looks better now.

Building The Movie Detail Page

The movie detail page displays the details of a single movie when it is clicked on.

Using Path Parameters

According to the Tanstack Official Docs:

Path params are used to match a single segment (the text until the next /) and provide its value back to you as a named variable. They are defined by using the $ character prefix in the path, followed by the key variable to assign it to.

To implement the movie details functionality, we will use path parameters, this will allow us to define dynamic routes via each movie id.

In the routes directory, create a new movies subdirectory, in the movies directory create a $movieId.tsx. This file will contain the logic for fetching an individual movie from TMDb API.

Enter the following code in $movieId.tsx:

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/movies/$movieId")({
 component: MovieDetail,
});

function MovieDetail() {
 return <h1>Movie!!!</h1>;
}
Enter fullscreen mode Exit fullscreen mode

The preceding code is a basic implementation of the movie detail functionality without data from an API. The MovieDetail component is rendered when the path matches /movies/$movieId, where $movieId stands for the id of the movie that is clicked on the index page.

Browser Output:

bandicam2024-04-2314-09-32-372-ezgif.com-video-to-gif-converter

Now, let's create a component for the movie details.

In the component directory, create a Movie.tsx file, and add the following code:

import type { Movie } from '../types';

export default function Movie({ movie }: { movie: Movie }) {
 return (
 <div className='flex'>
 <div className='flex-shrink w-1/4'>
 <img
 src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
 className='aspect-w-5 aspect-h-7 rounded-3xl'
 />
 </div>
 <div className='w-3/4'>
 <div className='font-bold text-2xl px-4'>{movie.title}</div>
 <div className='italic text-xl px-4 mb-5'>{movie.tagline}</div>
 <div className='pt-3 px-4'>
 <div className='italic'>{movie.overview}</div>
 <div className='flex justify-between pt-3 items-center'>
 <div>{movie.vote_average.toFixed(1)} out of 10</div>
 </div>
 <div className='grid grid-cols-[30%_70%] pt-3 gap-3'>
 <div className='font-bold text-right'>Runtime</div>
 <div>{movie.runtime} minutes</div>

 <div className='font-bold text-right'>Genres</div>
 <div>{movie.genres.map(({ name }) => name).join(', ')}</div>

 <div className='font-bold text-right'>Release Date</div>
 <div>{movie.release_date}</div>

 <div className='font-bold text-right'>Production Companies</div>
 <div>
 {movie.production_companies.map(({ name }) => name).join(', ')}
 </div>

 <div className='font-bold text-right'>Languages</div>
 <div>
 {movie.spoken_languages
 .map(({ english_name }) => english_name)
 .join(', ')}
 </div>
 </div>
 </div>
 </div>
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

Now, let's get the movie details from the API.

Enter the following code in the api.ts file.

....
// get Movie by Id
export async function getMovie(id: string) {
 const response = await fetch(
 `https://api.themoviedb.org/3/movie/${id}?language=en-US&api_key=${API_KEY}`
 ).then((r) => r.json());

 return response;
}
Enter fullscreen mode Exit fullscreen mode

The preceding code is a fetch request that retrieves details for individual movies based on their id.

Now, enter the following code in $movieId.tsx:

import { createFileRoute } from "@tanstack/react-router";
import { getMovie } from "../../api";
import Movie from "../../components/Movie";

export const Route = createFileRoute("/movies/$movieId")({
 component: MovieDetail,
 loader: ({ params: { movieId } }) => getMovie(movieId),
});

function MovieDetail() {
 const movie = Route.useLoaderData();
 return <Movie movie={movie} />;
}
Enter fullscreen mode Exit fullscreen mode

The above code does the following:

  • The getMovie() function is imported from api.tsx.
  • No need for loaderDeps, because we are working with path params. We simply pass the parameter into loader.
  • useLoaderData() is used to access data from the loader.
  • The <Movie/> component is used to display the movie detail on the UI.

Browser Output:

bandicam2024-04-2316-41-44-656-ezgif.com-video-to-gif-converter

Building Movie Search

The search feature will enable us to search for specific movies in our app. So, we will build a search route to implement this feature.

Search Params for State Management

We will be storing our search query in the URL via search params, the code below shows how this is done with Tanstack Router.

In the routes directory, create a search.tsx file. Add the following code:

import { createFileRoute, useNavigate} from "@tanstack/react-router";
import { useState } from "react";

interface SearchParams {
 query: string;
}

export const Route = createFileRoute("/search")({
 component: SearchRoute,
 validateSearch: (search: { query: string }): SearchParams => {
 return {
 query: (search.query as string) || "",
 };
 },
});

function SearchRoute() {
 const { query } = Route.useSearch();
 const navigate = useNavigate({ from: Route.id });
 const [newQuery, setNewQuery] = useState(query);

 return (
 <div className="p-2">
 <div className="flex gap-2">
 <input
 value={newQuery}
 onChange={(e) => {
 setNewQuery(e.target.value);
 }}
 onKeyUp={(e) => {
 if (e.key === "Enter") {
 navigate({
 search: (old: { query: string }) => ({
 ...old,
 query: newQuery,
 }),
 });
 }
 }}
 className="border-2 border-gray-300 rounded-md p-1 text-black w-full"
 />
 <button
 onClick={() => {
 navigate({
 search: (old: { query: string }) => ({
 ...old,
 q: newQuery,
 }),
 });
 }}
 >
 Search
 </button>
 </div>
 // Results
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

The preceding code does the following:

  • We define the /search route with the createFileRoute() function from TanStack Router.
  • The validateSearch option is used to validate the search params of the /search route, it also returns a typed SearchParams object with a query property set to string.
  • The SearchRoute component accesses the search param via Route.useSearch().
  • We use the useNavigate function from TanStack Router to programmatically navigate from the /search route β€”useNavigate({ from: Route.id }).[]
  • The useState hook for React is used to update the state of the SearchRoute component based on the search param query.
  • In the return block, we update the search param based on the input value typed in by the user as a search query. Also, the useNavigate function is used to update search string(query) to what is currently being typed by the user via the SearchParamOptions type

Browser Output:

bandicam2024-04-2412-33-31-989-ezgif.com-video-to-gif-converter

In the above demo, you will notice that the search param in the URL got updated to the query that was entered in the search box.

Showing Search Results

With the search functionality in place, the next step is to display the search result(s). For this, we will create a nested route inside the search route, which will also serve as an index route of the search route.

Nested Routing

The Outlet Componet is used to create nested routes in TanStack Router. Since the search route will be the parent route of the nested route, the outlet component is used here:

// src\routes\search.tsx
import { createFileRoute, useNavigate, Outlet } from "@tanstack/react-router";
....

function SearchRoute() {
 ....
 return (
 <div className="p-2">
 <div className="flex gap-2">
 .....
 </div>
 <Outlet />
 </div>
 );
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code, the <Outlet/> component was added to the SearchRoute component in search.tsx. This is where the result(s) for our search query will be displayed.

First, let's retrieve the search results data from the API. Add the following code in api.ts

......
// Search Movie
export async function searchMovie(query: string = "") {
 const response = await fetch(
 `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(
 query
 )}&include_adult=false&language=en-US&page=1&api_key=${API_KEY}`
 )
 .then((r) => r.json())
 .then((r) => r.results);
 return response;
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code is the logic for retrieving search results from the TMDb API.

Next, in the routes directory, create the search.index.tsx file, and add the following code:

import { createFileRoute } from "@tanstack/react-router";
import MovieCards from "../components/MovieCards";
import { searchMovie } from "../api";

interface SearchParams {
 query: string;
}

export const Route = createFileRoute("/search/")({
 component: SearchRoute,
 loaderDeps: ({ search: { query } }) => ({ query }),
 loader: async ({ deps: { query } }) => {
 const searched_movies = await searchMovie(query);
 return {
 searched_movies,
 };
 },
 validateSearch: (search: { query: string }): SearchParams => {
 return {
 query: (search.query as string) || "",
 };
 },
});

function SearchRoute() {
 const { searched_movies } = Route.useLoaderData();

 return (
 <>
 <MovieCards movies={searched_movies || []} />
 </>
 );
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code:

  • We defined the /search/ file route, which is the index route of the search route /search
  • validateSearch is used to get the query, which will enable the interior index(/search/) to be routed into the <Outlet/> of the parent route(/search).
  • The searchMovie() function retrieves movie results from the API via the query parameter.
  • loaderDeps caches the search query, which is then used by the loader to fetch the search results.
  • Finally, useLoaderData allows the search results to be accessed by the SearchRoute component. SearchRoute renders the results on the UI via the <MovieCards/> component.

Browser Output:

bandicam2024-04-2414-40-16-751-ezgif.com-video-to-gif-converter

Streaming Search Results

In this section, we are going to add some lazy-loading and code-splitting to our application.

We will display details for the first movie returned from the search results at the top of the page, the defer function and Await component from TanStack Router will be used to enable other search results to be displayed sooner, without waiting for the details of the first movie to be rendered.

Sample UI:

sample

Fallbacks with React Suspense

Enter the following code in search.index.tsx:

import { createFileRoute, defer, Await } from "@tanstack/react-router";
import { Suspense } from "react";

import MovieCards from "../components/MovieCards";
import Movie from "../components/Movie";

import { searchMovie, getMovie } from "../api";

interface SearchParams {
 query: string;
}

export const Route = createFileRoute("/search/")({
 component: SearchRoute,
 loaderDeps: ({ search: { query } }) => ({ query }),
 loader: async ({ deps: { query } }) => {
 const searched_movies = await searchMovie(query);
 return {
 searched_movies,
 firstMovie: searched_movies?.[0]?.id
 ? defer(getMovie(searched_movies[0].id))
 : null,
 };
 },
 validateSearch: (search: { query: string }): SearchParams => {
 return {
 query: (search.query as string) || "",
 };
 },
});

function SearchRoute() {
 const { searched_movies, firstMovie } = Route.useLoaderData();

// fallbacks with React Suspense
 return (
 <>
 {firstMovie && (
 <div className="my-5">
 <Suspense fallback={<div>Loading...</div>}>
 <Await promise={firstMovie}>
 {(movie) => {
 return <Movie movie={movie} />;
 }}
 </Await>
 </Suspense>
 </div>
 )}
 <MovieCards movies={searched_movies || []} />
 </>
 );
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code:

  • getMovie() calls the same API endpoint as movie details page in $movieId.tsx
  • In loader, the data(first movie details) from getMovie() is deferred if the data is available via the defer function.
  • defer() allows us to lazy-load the data(first movie details), in case the API endpoint for the movie details takes time, we can show other search results before the first movie details.
  • In the return block of the SearchRoute component:
    • Suspense is used to provide a fallback until the promise(first movie details) from the Await component is resolved.
    • The first movie details is rendered via <Movie/> component to the UI.

Browser Output:

bandicam2024-04-2415-53-47-513-ezgif.com-optimize

Summary

In this article, you built a single-page movie application that lets you view movies, search for movies, and get the details of individual movies with React and TanStack Router. You learned how to use some basic and advanced features of TanStack Router like typesafe routes and links, nested layouts, advanced data loader capabilities, search params as a React State replacement, and integration with React Suspense.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .