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.
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.
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!