Skip to content

Using Server Functions and Tanstack Query

Posted on:September 1, 2024

I’ve had my eye on a new javascript framework called Tanstack Start for awhile now. I’ve only recently got some time to play around with it. I wanted to compare how things felt compared to SolidStart as it’s built on a similar foundation using Vinxi.

My impressions are still early but it seems great so far. Especially when combined with Tanstack Query. I think most people will want to use the two in combination to handle their data.

Let’s jump in and see what we can learn!

Initial starter

I have an initial start branch setup on a repo so you don’t have to go through the initial setup already mentioned in the Tanstack Start guide

git clone -b start git@github.com:brenelz/tanstack-start-playground.git

Setting up Tanstack Query

Let’s first setup Tanstack Query in our app as we are going to want to use it shortly.

Install the following packages:

pnpm add @tanstack/react-query @tanstack/react-router-with-query

Now go to your app/routes/__root.tsx and replace createRootRoute with createRootRootWithContext

import { QueryClient } from "@tanstack/react-query";
import { createRootRouteWithContext } from "@tanstack/react-router";

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient;
}>()({
  // ...
});

Next go to app/router.tsx and replace createRouter with the following:

import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import { QueryClient } from "@tanstack/react-query";

export function createRouter() {
  const queryClient = new QueryClient();

  return routerWithQueryClient(
    createTanStackRouter({
      routeTree,
      context: { queryClient },
      defaultPreload: "intent",
    }),
    queryClient
  );
}

We have now setup the router to know about our queryClient.

Writing our first server function

So in order to query our data we need a server function. Now in this case we have a simple playlist object but in a real project this would probably come from a database.

In the app/lib directory create an api.ts with the following:

import { createServerFn } from "@tanstack/start";
import { playlists, songs } from "./data";

export const getPlaylists = createServerFn("GET", async () => {
  return playlists;
});

export const getPlaylist = createServerFn("GET", async (id: string) => {
  return playlists.find(playlist => playlist.id === id);
});

export const getSongs = createServerFn("GET", async () => {
  return songs;
});

You will notice there is no "use server" like Solid. This is a stylistic choice to get better type safety.

Since these are just getting data we can use the method GET. Later on for mutating data we will see how we can use POST.

Writing our first query

The next step is calling our server function from a query and show our playlists on the homepage.

In app/routes/index.tsx add the following:

import {
  queryOptions,
  useMutation,
  useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Suspense } from "react";
import { getPlaylists } from "../lib/api";

export const playlistsQueryOptions = () =>
  queryOptions({
    queryKey: ["playlists"],
    queryFn: getPlaylists,
    staleTime: Infinity,
  });

export const Route = createFileRoute("/")({
  component: Home,
});

function Home() {
  return (
    <Suspense fallback="Loading Playlists...">
      <Playlists />
    </Suspense>
  );
}

function Playlists() {
  const { data: playlists } = useSuspenseQuery(playlistsQueryOptions());

  return (
    <>
      <ul>
        {playlists.map(playlist => (
          <li key={playlist.id}>
            <Link to="/playlists/$id" params={{ id: playlist.id }}>
              {playlist.title}
            </Link>
          </li>
        ))}
      </ul>
    </>
  );
}

You should now see a list of playlists on your homepage when you pnpm dev your app.

list of playlists

This isn’t a specific deep dive on Tanstack Query. I highly suggest the Query.gg course if you want to learn more about it.

On the Tanstack Router side there is one cool thing here. Type safe links! Using the Link commponent it autocompletes your routes.

autocomplete for routes

Using a loader

We can take our data loading a bit further and specify a loader on this route. This will allow Tanstack Router to preload our data when links are hovered as well as load our data as early as possible.

Make your export Const Route look like this:

export const Route = createFileRoute("/")({
  component: Home,
  loader: ({ context }) => {
    context.queryClient.prefetchQuery(playlistsQueryOptions());
  },
});

We get access to our queryClient off the context so its super easy to do this prefetch.

Individual playlist page

Edit the app/routes/playlists/$id.tsx file as follows:

import { queryOptions, useSuspenseQueries } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { Suspense } from "react";
import { getPlaylist, getSongs } from "../../lib/api";

export const playlistQueryOptions = (id: string) =>
  queryOptions({
    queryKey: ["playlist", id],
    queryFn: () => getPlaylist(id),
    staleTime: Infinity,
  });

export const songsQueryOptions = () =>
  queryOptions({
    queryKey: ["songs"],
    queryFn: getSongs,
    staleTime: Infinity,
  });

export const Route = createFileRoute("/playlists/$id")({
  component: Playlist,
  loader: ({ context, params }) => {
    context.queryClient.prefetchQuery(playlistQueryOptions(params.id));
    context.queryClient.prefetchQuery(songsQueryOptions());
  },
});

function Playlist() {
  return (
    <Suspense fallback="Loading...">
      <PlaylistItem />
    </Suspense>
  );
}

function PlaylistItem() {
  const { id } = Route.useParams();
  const [{ data: playlist }, { data: songs }] = useSuspenseQueries({
    queries: [playlistQueryOptions(id), songsQueryOptions()],
  });

  return (
    <div>
      <h1>{playlist.title}</h1>
      <img src={playlist.cover} width="100" />
      <h2>Songs</h2>
      <ul>
        {songs.map(song => (
          <li key={song.id}>{song.title}</li>
        ))}
      </ul>
      <p>
        <Link to="/">Back</Link>
      </p>
    </div>
  );
}

Probably the most interesting thing in this component is that we are using useSuspenseQueries instead of useSuspenseQuery. This allows us to make these two requests in parallel as they are unrelated to each other.

Now go to your homepage and hover over one of the playlists. You will see it will actually preload some of our data for us because of the loader we just added!

Also it’s worth mentioning the $id in the filename is how Tanstack Router does dynamic route params. This can then be pulled off the url using Route.useParams(). We also get easy access this in the loader.

Mutations

This is all great but getting data is only part of the puzzle. How do we mutate the data on the server?

Creating the server function should feel similar but instead of using a GET you use a POST. Add this to app/lib/api.ts

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

export const addPlaylist = createServerFn("POST", async () => {
  playlists.push({
    id: "6",
    title: "Cow songs",
    cover:
      "https://res.cloudinary.com/dp3ppkxo5/image/upload/v1693776474/spotify-astro/R-15112137-1586815179-1911_fsyl58.jpg",
    artists: ["Saint Hilda", "Canada Buffalo"],
  });

  return redirect({
    to: "/playlists/$id",
    params: { id: "6" },
  });
});

export const removePlaylist = createServerFn("POST", async () => {
  playlists.pop();

  return redirect({
    to: "/",
  });
});

After a mutation you sometimes want to redirect somewhere even if its the same page. Here we use the redirect helper from Tanstack Router.

Now how do we use these functions in our index.tsx. It would be with useMutation of course!

import { addPlaylist, removePlaylist } from "../lib/api";
import { useServerFn } from "@tanstack/start";

function Playlists() {
  const { data: playlists } = useSuspenseQuery(playlistsQueryOptions());

  const addPlaylistFn = useMutation({
    mutationFn: useServerFn(addPlaylist),
  });

  const removePlaylistFn = useMutation({
    mutationFn: useServerFn(removePlaylist),
  });

  return (
    <>
      <ul>
        {playlists.map(playlist => (
          <li key={playlist.id}>
            <Link to="/playlists/$id" params={{ id: playlist.id }}>
              {playlist.title}
            </Link>
          </li>
        ))}
      </ul>
      <p>
        <button onClick={() => addPlaylistFn.mutate()}>Add Playlist</button>
      </p>
      <p>
        <button onClick={() => removePlaylistFn.mutate()}>
          Remove Playlist
        </button>
      </p>
    </>
  );
}

In order for redirects to work you will need to wrap the server function with the useServerFn helper.

If you click on the button nothing seems to happen even though the network request is firing. You can see the new data by refreshing the page but we want to automatically get the new data.

To do this you can invalidate individual queries using the onSuccess of useMutation or in our example we will go nuclear and clear the cache on any mutation. We just need to configure the QueryClient to do this.

Open up app/router.tsx and replace const queryClient = new QueryClient(); with the following:

import { MutationCache, QueryClient } from "@tanstack/react-query";

const queryClient: QueryClient = new QueryClient({
  mutationCache: new MutationCache({
    onSettled: () => {
      if (queryClient.isMutating() === 1) {
        return queryClient.invalidateQueries();
      }
    },
  }),
});

This will invalidate the queries when the mutations have settled. Now if you add a playlist, or remove a playlist the list will update accordingly.

Conclusion

This ends our quick dive into the new Tanstack world. I definitely believe Tanstack Start + Router + Query are going to be great combination of tools for people to use in the future!

Make sure to checkout the Tanstack Router docs as well as the Server Functions docs as those are the two main things we covered today. Best of luck coding!