Tanstack Router + Query

As the best modern software combo

About me

  • 💻 Fullstack Dev | 4+ years
  • 🛠️ JS/TS, React, Next.js, Nest.js
  • 🚴‍♂️ Gravel Biker
  • ☕ Coffee Lover

Tanstack Router is the best React router and Tanstack Query is the best server state manager

  • file based routing,
  • nested routes & layouts,
  • loaders per route,
  • paralel data fetching,
  • route caching and prefetching,
  • error boundaries and handling.

Tanstack Router has

  • Typesafe navigation
  • Typesafe JSON-first Search Params,
  • Middleware for search params,
  • Granular cache invalidation,
  • Client site persisted cache,
  • Route data lifecycle API,
  • No hosting vendor-lock,

Tanstack Router is better

Routing

Loading data

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  loader: async () => {
    const posts = await getPosts();
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onError: sendInfoToSentry,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
});

const Posts = () => {
  const { posts, categories } = 
        useLoaderData({ from: "/posts" });

  return (
    <div>
      <DisplayPosts posts={posts} />
      <Outlet />
    </div>
  );
};

Search Params validation

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  loader: async () => {
    const posts = await getPosts();
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
  onError: sendInfoToSentry,
});

Search Params validation

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  loader: async () => {
    const posts = await getPosts();
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
  onError: sendInfoToSentry,
});

const PostsSearchSchema = z.object({
  limit: z.number().min(1).max(100).default(10).optional(),
  offset: z.number().min(0).default(0).optional(),
  category: z.enum(["IT", "Business", "Marketing"]).optional(),
});

Search Params validation

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  validateSearch: zodSearchValidator(PostsSearchSchema),
  loaderDeps: ({ search }) => {
    return {
      limit: search.limit,
      offset: search.offset,
      category: search.category,
    };
  },
  loader: async () => {
    const posts = await getPosts();
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
  onError: sendInfoToSentry,
});

const PostsSearchSchema = z.object({
  limit: z.number().min(1).max(100).default(10).optional(),
  offset: z.number().min(0).default(0).optional(),
  category: z.enum(["IT", "Business", "Marketing"]).optional(),
});

Search Params validation

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  validateSearch: zodSearchValidator(PostsSearchSchema),
  loaderDeps: ({ search }) => {
    return {
      limit: search.limit,
      offset: search.offset,
      category: search.category,
    };
  },
  loader: async () => {
    const posts = await getPosts();
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
  onError: sendInfoToSentry,
  search: {
    middlewares: [
      retainSearchParams(["limit", "offset"]),
      stripSearchParams(["limit", "offset"]),
    ],
  },
});

const PostsSearchSchema = z.object({
  limit: z.number().min(1).max(100).default(10).optional(),
  offset: z.number().min(0).default(0).optional(),
  category: z.enum(["IT", "Business", "Marketing"]).optional(),
});

Search Params validation

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  validateSearch: zodSearchValidator(PostsSearchSchema),
  loaderDeps: ({ search }) => {
    return {
      limit: search.limit,
      offset: search.offset,
      category: search.category,
    };
  },
  loader: async ({ deps: { limit, offset, category } }) => {
    const posts = await getPosts({ limit, offset, category });
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
  onError: sendInfoToSentry,
  search: {
    middlewares: [
      retainSearchParams(["limit", "offset"]),
      stripSearchParams(["limit", "offset"]),
    ],
  },
});

const PostsSearchSchema = z.object({
  limit: z.number().min(1).max(100).default(10).optional(),
  offset: z.number().min(0).default(0).optional(),
  category: z.enum(["IT", "Business", "Marketing"]).optional(),
});

Search Params validation

// posts.tsx /posts

export const Route = createFileRoute("/posts")({
  validateSearch: zodSearchValidator(PostsSearchSchema),
  loaderDeps: ({ search }) => {
    return {
      limit: search.limit,
      offset: search.offset,
      category: search.category,
    };
  },
  loader: async ({ deps: { limit, offset, category } }) => {
    const posts = await getPosts({ limit, offset, category });
    const categories = await getCategories();

    return { posts, categories };
  },
  component: Posts,
  staleTime: 1000 * 60 * 5, // 5 minutes
  gcTime: 1000 * 60 * 60 * 24, // 1 day
  notFoundComponent: NotFound,
  errorComponent: ErrorComponent,
  pendingComponent: PendingComponent,
  onEnter: sendToAnalyticsEnter,
  onLeave: sendToAnalyticsLeave,
  onError: sendInfoToSentry,
  search: {
    middlewares: [
      retainSearchParams(["limit", "offset"]),
      stripSearchParams(["limit", "offset"]),
    ],
  },
});

const PostsSearchSchema = z.object({
  limit: z.number().min(1).max(100).default(10).optional(),
  offset: z.number().min(0).default(0).optional(),
  category: z.enum(["IT", "Business", "Marketing"]).optional(),
  modal: z.object({
    isOpen: z.boolean().default(false),
    postId: z.string().optional(),
    action: z.enum(['create', 'edit', 'delete']).optional()
  }).default({ isOpen: false }).optional()
});

Granular fetching & caching

// posts.$postId.tsx /posts/$postId

export const Route = createFileRoute("/posts/$postId")({
  loader: async ({ params: { postId } }) => {
    const post = await getPost(postId);
    return post;
  },
  component: PostComponent,
});

const PostComponent = () => {
  const { categories } = useLoaderData({ from: "/posts" });
  const postData = useLoaderData({ from: "/posts/$postId" });

  return ...
};

Granular fetching & caching

// posts.$postId.tsx /posts/$postId

export const Route = createFileRoute("/posts/$postId")({
  loader: async ({ params: { postId }, context: { queryClient } }) => {
    await queryClient.ensureQueryData(postQueryOptions(postId));
    await queryClient.ensureQueryData(categoriesQueryOptions());
  },
  component: PostComponent,
});

const PostComponent = () => {
  const postId = useParams({ from: "/posts/$postId" }).postId;
  const {
    data: { author, content, id },
  } = useSuspenseQuery(postQueryOptions(postId));
  const { data: categories } = useSuspenseQuery(categoriesQueryOptions());

  return ...;
};

What to keep in mind?

  • No server-side*

  • SEO

  • Less popular than Remix and Next.js

  • Vendor lock-in ❔

  • JavaScript is required to run the page

Summary

  • Typesafe JSON-first Search Params,
  • Fully typesafe navigation,
  • granular cache and invalidation,
  • no forced abstraction,
Made with Slides.com