- Published on
TanStack Start to have more fun
I love coding. But this last year has been somewhat uninspired. I find myself experimenting with technologies that offer some promise but ultimately fall short. Unsurprisingly, Next.js is leading the way through the React ecosystem but it's becoming somewhat bloated imo and obviously trying to push everyone to use Vercel's servers ($$$). Then there is HTMX, which I am still undecided on. I've done some experimenting and I rather enjoy it but, as predominantly an interface engineer, I find it difficult to build 'delightful' user experiences that scale with HTMX. Sometimes you just need some Javascript.
Being a fan of React Query for quite some time, I have been following the evolution of Tanner Linsley's creations and they are all in line with my mental model of a React application.
I have been around the block when it comes to React libraries. I remember wrapping my mind around the pattern of redux thunks and redux sagas. They are probably still alive and well in some legacy codebase somewhere. Of course, the patterns you adopt are ultimately the result of team alignment. There still is something I miss about separating global state from component logic. It felt so… simple. If I were coding inside a component, I wouldn't have to worry about where or how the data was getting there because that was a concern of the reducer. I just knew the data would be correct by the time it entered the component.
But this is not about Redux. This is about something else entirely. The first time I tried react-query, or rather, TanStack Query, I was confused. I first thought to myself "Why am I fetching the same thing everywhere?", "How do I use a state management library with this?"… you can probably see where I am going. Once I understood how it's caching pattern works, it all began to click. Once you've created a dictionary of request id's, you have a location of self documenting endpoints. For example:
interface User {
id: string;
name: string;
email: string;
}
type QueryKey<T extends readonly string[]> = T;
// endpoints.ts
const ENDPOINTS = {
USER: {
id: ['user'] as QueryKey<['user']>,
byId: (userId: string) => ({
queryKey: ['user', userId] as QueryKey<['user', string]>,
url: `/users/${userId}`,
}),
all: {
queryKey: ['users'] as QueryKey<['users']>,
url: '/users',
},
},
};
// hooks.ts
const useUser = (userId: string) => {
return useQuery({
...ENDPOINTS.USER.byId(userId),
queryFn: async (): Promise<User> => {
const response = await fetch(ENDPOINTS.USER.byId(userId).url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
},
});
};
// UserProfile.tsx
const UserProfile = ({ userId }: { userId: string }) => {
const { data: user, isLoading, error } = useUser(userId);
...
The key point I'm making here is regarding the const ENDPOINTS
variable. I've worked in projects where there are thousands of endpoints, so this pattern does scale well, and provides clarity to the reader. For example, we can modularize USER
by placing it in a separate directory. This approach gives the team a centralized, self-documenting reference for all their query keys and endpoints, making the codebase more maintainable as it grows.
Then this last week I saw a presentation about TanStack Start that caught my attention. I've really enjoyed the pattern of loaders introduced by Remix years ago, and when he mentioned their full stack framework is using this pattern as well, I poured myself a tall glass of Kool-Aid.
From then
In the days of Redux, we all lamented the huge amounts of boilerplate just to update a piece of global state. But like any good problem, the community rallied around a solution and redux-toolkit was born (originally named redux-starter-kit). While this was a great simplification to the boilerplate, some theoretical improvements were exposed. Such as, do we really need to manage so much state in the first place? Can we default to components managing their own state and lifting state out to a global context when appropriate?
Around this time, React context was introduced and we had a solution. But every solution often exposes more problems. You see, react context is great at giving you a tool to pass state wherever you wanted, but if you aren't careful, you will be re-rendering your entire component tree without realizing it. Then came React.memo, and memo hooks, and well… we started having to manage rendering ourselves. Wasn't this the beauty of React in the first place? Wasn't it suppose to magically update your UI in the most efficient way? Well, now we enter the chapter of the React Compiler.
This is where I change directions. Because while the (amazing) React team was introducing these solutions, other developers were creating different solutions. Such as React-Query. React-Query dared to ask "What if we cache the responses from repeated requests and allow the component to select the data it wishes" (or something to this effect). Since we are fetching in a waterfall pattern already (as child components render, they fetch their corresponding data), let's abstract the data layer and provide access through a unique identifier. Of course this was all only possible after the invention of react-hooks.
Now that the developer landscape is turning it's gaze back to the server, and many are resurrecting the return of a PHP like framework, the TanStack community is coming in swinging with a new framework to (vaguely) challenge the direction of the tide. Everyone is talking about server components and data streaming while the Tanner team is looking for a different direction. I love it.
Something I love about the Remix approach was progressive enhancement first. It's patterns force you to build with browser technology that has existed for a long time and just works. I remember thinking, after a few years into my career, "why are we rewriting everything in javascript?". The community did notice this as well as the the size of our javascript bundles being shipped to user grew. This influenced the introduction of form state hooks in React, and other goodies to hook into the underlying, long existing, browser tech.
As someone who has spent many hours on numerous Next.js projects, I understand the strength in it's opinionated approach to web development. Routing defined by file structure has little cognitive overhead. Though with great power comes great responsibility. Next.js has grown to be so unique that it's hard to see how lessons from Next.js translate to other programming paradigms.
That's probably enough ramblings for today.