Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Thu, 03 Jul 2025 14:47:15 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Satisfies in TypeScript https://frontendmasters.com/blog/satisfies-in-typescript/ https://frontendmasters.com/blog/satisfies-in-typescript/#comments Thu, 03 Jul 2025 14:47:14 +0000 https://frontendmasters.com/blog/?p=6443 This is a post about one of TypeScript’s less common features: the satisfies keyword. It’s occasionally incredibly useful, and knowing how to properly wield it is a valuable trick to have up your sleeve. Let’s take a look!

A quick intro on structural typing

In a nutshell, structural typing means that TypeScript cares only about the structure of your values, not the types they were declared with. That means the following code contains no errors:

class Thing1 {
  name: string = "";
}

class Thing2 {
  name: string = "";
}

let thing1: Thing1 = new Thing1();
let thing2: Thing1 = new Thing2();
let thing3: Thing1 = { name: "" };

Types are essentially contracts, and TypeScript cares only that you satisfy the contract with something that has what the original type specified.

Interestingly, this also means you can supply extraneous, superfluous “stuff” when satisfying types: the following also has no errors.

const val = {
  name: "",
  xyz: 12,
};

let thing4: Thing1 = val;

The Thing1 type only calls for a name property that’s a string. If you also specify other properties, TypeScript is (usually) OK with it. This might seem surprising coming from other languages, but it’s a pragmatic tradeoff given that TypeScript’s primary purpose is to provide some manner of type safety to a completely untyped programming language: JavaScript.

I said usually above because occasionally TypeScript will be a bit stricter about not allowing “extra” values like we saw above. In particular, when assigning an object literal to a variable that’s declared with a type, TypeScript will require a strict matching.

let thing4: Thing1 = val;

const val2: Thing1 = {
  name: "",
  xyz: 12,
  // Error: Object literal may only specify known properties, and 'xyz' does not exist in type 'Thing1'
};

This is called “excess property checking.” It happens when assigning an object literal to a variable with a declared type, like we just saw, and also when passing an object literal to a function parameter that has a declared type.

The satisfies keyword

To provide the most simplistic example of using satisfies, let’s go back to this code

const val3 = {
  name: "",
  xyz: 12,
};

Right now val3 has the inferred type

{
  name: string;
  xyz: number;
}

If we wanted, we could write this code like this:

const val3 = {
  name: "",
  xyz: 12,
  // Error: Object literal may only specify known properties, and 'xyz' does not exist in type 'Thing1'
} satisfies Thing1;

That produced the same error we saw before, and the same error we would have gotten if we had declared val3 as Thing1.

const val3: Thing1 = {
  name: "",
  xyz: 12,
  // Error: Object literal may only specify known properties, and 'xyz' does not exist in type 'Thing1'
};

The satisfies keyword allows you to assert that a certain value “satisfies” a given type, while preventing a wider type from being inferred.

Bear with me.

You’re probably thinking that this is completely pointless, since we can just move Thing1 up into a proper type declaration, and even save a few keystrokes while doing so!

But not all situations lend themselves to this solution.

Let’s take a look at a slightly more complex, more realistic example.

The satisfies Keyword in the Wild

This is a situation I actually ran into. I’ll do my best to simplify it, while keeping the realistic parts.

Imagine we’re writing an inventory management system. We have an inventory item type.

type InventoryItem = {
  sku: string;
  description: string;
  originCode?: string;
};

Maybe we have some external backend systems we need to fetch data from.

type BackendResponse = {
  item_sku: string;
  item_description: string;
  item_metadata: Record<string, string>;
  item_origin_code: string;
};

function getBackendResponse(): BackendResponse[] {
  return [];
}

The getBackendResponse function is hard coded to return an empty array, but just pretend it makes a request and returns actual data. Then pretend we want to take that data and actually insert it. We have a function to do the inserting; we’re only interested in the types though, so we’ll leave the implementation empty

function insertInventoryItems(items: InventoryItem[]) {}

Let’s put things together. Fetch some items from our external system, manipulate them into the proper structure for our own InventoryItem type, and then call our insertInventoryItems function

function main() {
  const backendItems = getBackendResponse();
  insertInventoryItems(
    backendItems.map(item => {
      return {
        sku: item.item_sku,
        description: item.item_description,
        originCodeXXXXX: item.item_origin_code,
      };
    })
  );
}

Unfortunately, this code has no errors, even though we completely fat-fingered the originCode property.

You already know that TypeScript will allow you to provide “extra” properties in places where excess property checking doesn’t exist, but you may be wondering why it’s not an error that we completely left off the real originCode property. The reason is that this is an optional property! That makes it all the more important that we disallow excess cruft.

You might be thinking that we can just restructure our code so that excess property checking is in place, and we certainly could do that

function main() {
  const backendItems = getBackendResponse();
  insertInventoryItems(
    backendItems.map(item => {
      const result: InventoryItem = {
        sku: item.item_sku,
        description: item.item_description,
        originCodeXXXXX: item.item_origin_code,
        // Error: Object literal may only specify known properties, but 'originCodeXXXXX'
        // does not exist in type 'InventoryItem'. Did you mean to write 'originCode'
      };
      return result;
    })
  );
}

This works and produces the error we want to see. But it’s just a byproduct of the (frankly weird) way we chose to write it, and this protection would disappear if anyone were to come along, see this weird, pointless intermediate variable declaration, and “helpfully” refactor the code to just immediately return the object literal like we just had.

The better solution is to use satisfies to prevent the unwanted widening; that’s why it exists!

function main() {
  const backendItems = getBackendResponse();
  insertInventoryItems(
    backendItems.map(item => {
      return {
        sku: item.item_sku,
        description: item.item_description,
        originCodeXXXXX: item.item_origin_code,
        // Error: Object literal may only specify known properties, but 'originCodeXXXXX'
        // does not exist in type 'InventoryItem'. Did you mean to write 'originCode'
      } satisfies InventoryItem;
    })
  );
}

Now we’re back to the more idiomatic code we started with, with the same strict checks we’re looking for.

Before we wrap up, let’s briefly consider this alternative you might be wondering about

function main() {
  const backendItems = getBackendResponse();
  insertInventoryItems(
    backendItems.map(item => {
      return {
        sku: item.item_sku,
        description: item.item_description,
        originCodeXXXXX: item.item_origin_code,
      } as InventoryItem;
    })
  );
}

This produces no errors at all. The as keyword is a typecast. It’s something to avoid; it essentially allows you to “lie” to the type checker and assert that a given expression matches a given type. In this case, the cast pointless because this object already matches the InventoryItem type. It has a sku, and a description. It also has some extra “stuff” but TypeScript doesn’t really mind. It’s the satisfies keyword which additionally forces TypeScript to also not allow a wider type, and therefor start minding about extra properties.

For completeness, this version of the casting code actually does fail

function main3() {
  const backendItems = getBackendResponse();
  insertInventoryItems(
    backendItems.map(item => {
      return {
        sku: item.item_sku,
        descriptionXXX: item.item_description,
        // Error: Conversion of type '{ sku: string; descriptionXXX: string; originCodeXXXXX: string; }' to type
        // 'InventoryItem' may be a mistake because neither type sufficiently overlaps with the other. If this
        // was intentional, convert the expression to 'unknown' first. Property 'description' is missing in type
        // '{ sku: string; descriptionXXX: string; originCodeXXXXX: string; }' but required in type 'InventoryItem'.
        originCodeXXXXX: item.item_origin_code,
      } as InventoryItem;
    })
  );
}

TypeScript will allow you to lie, but only so far. If the cast makes absolutely no sense, TypeScript won’t allow it. As the error indicates, if you, for some reason, actually wanted to go through with this code, you could do:

<code>as unknown as InventoryItem;</code>

since unknown is a “top” type, which means anything can be cast to it, and from it.

Wrapping up

Use satisfies when you want to prevent type widenings, in situations where a top-level variable declaration doesn’t quite fit well.

]]>
https://frontendmasters.com/blog/satisfies-in-typescript/feed/ 2 6443
What’s the difference between ESLint and TypeScript? https://frontendmasters.com/blog/whats-the-difference-between-eslint-and-typescript/ https://frontendmasters.com/blog/whats-the-difference-between-eslint-and-typescript/#respond Wed, 23 Apr 2025 22:58:43 +0000 https://frontendmasters.com/blog/?p=5665 What is the difference between ESLint and TypeScript? Perhaps that feels like a strange question as ESLint is a, uhm, linter, and TypeScript is a language that compiles to JavaScript.

Josh Goldberg writes how there is some overlap:

While ESLint and TypeScript operate differently and specialize in different areas of code defects, there is some overlap. Specific types of code defects straddle the line between “best practices” and “type safety,” and so can be caught by both tools.

I could imagine asking (or having to answer) this in a job interview.

The two catch different areas of code defects and come with different philosophies around configurability and extensibility.

  • ESLint checks that code adheres to best practices and is consistent, enforcing what you should write.
  • TypeScripts checks that code is “type-safe”, enforcing what you can write.
]]>
https://frontendmasters.com/blog/whats-the-difference-between-eslint-and-typescript/feed/ 0 5665
Loading Data with TanStack Router: react-query https://frontendmasters.com/blog/tanstack-router-data-loading-2/ https://frontendmasters.com/blog/tanstack-router-data-loading-2/#comments Thu, 21 Nov 2024 18:11:14 +0000 https://frontendmasters.com/blog/?p=4492 TanStack Query, commonly referred to as react-query, is an incredibly popular tool for managing client-side querying. You could create an entire course on react-query, and people have, but here we’re going to keep it brief so you can quickly get going.

Article Series

Essentially, react-query allows us to write code like this:

const { data, isLoading } = useQuery({
  queryKey: ["task", taskId],
  queryFn: async () => {
    return fetchJson("/api/tasks/" + taskId);
  },
  staleTime: 1000 * 60 * 2,
  gcTime: 1000 * 60 * 5,
});

The queryKey does what it sounds like: it lets you identify any particular key for a query. As the key changes, react-query is smart enough to re-run the query, which is contained in the queryFn property. As these queries come in, TanStack tracks them in a client-side cache, along with properties like staleTime and gcTime, which mean the same thing as they do in TanStack Router. These tools are built by the same people, after all.

There’s also a useSuspenseQuery hook which is the same idea, except instead of giving you an isLoading value, it relies on Suspense, and lets you handle loading state via Suspense boundaries.

This barely scratches the surface of Query. If you’ve never used it before, be sure to check out the docs.

We’ll move on and cover the setup and integration with Router, but we’ll stay high level to keep this post a manageable length.

Setup

We need to wrap our entire app with a QueryClientProvider which injects a queryClient (and cache) into our application tree. Putting it around the RouterProvider we already have is as good a place as any.

const queryClient = new QueryClient();

const Main: FC = () => {
  return (
    <>
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router} context={{ queryClient }} />
      </QueryClientProvider>
      <TanStackRouterDevtools router={router} />
    </>
  );
};

Recall from before that we also passed our queryClient to our Router’s context like this:

const router = createRouter({ 
  routeTree, 
  context: { queryClient }
});

And:

type MyRouterContext = {
  queryClient: QueryClient;
};

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: Root,
});

This allows us access to the queryClient inside of our loader functions via the Router’s context. If you’re wondering why we need loaders at all, now that we’re using react-query, stay tuned.

Querying

We used Router’s built-in caching capabilities for our tasks. For epics, let’s use react-query. Moreover, let’s use the useSuspenseQuery hook, since managing loading state via Suspense boundaries is extremely ergonomic. Moreover, Suspense boundaries is exactly how Router’s pendingComponent works. So you can use useSuspenseQuery, along with the same pendingComponent we looked at before!

Let’s add another (contrived) summary query in our epics layout (route) component.

export const Route = createFileRoute("/app/epics")({
  component: EpicLayout,
  pendingComponent: () => <div>Loading epics route ...</div>,
});

function EpicLayout() {
  const context = Route.useRouteContext();
  const { data } = useSuspenseQuery(epicsSummaryQueryOptions(context.timestarted));

  return (
    <div>
      <h2>Epics overview</h2>
      <div>
        {data.epicsOverview.map(epic => (
          <Fragment key={epic.name}>
            <div>{epic.name}</div>
            <div>{epic.count}</div>
          </Fragment>
        ))}
      </div>

      <div>
        <Outlet />
      </div>
    </div>
  );
}

To keep the code somewhat organized (and other reasons we’ll get to) I stuck the query options into a separate place.

export const epicsSummaryQueryOptions = (timestarted: number) => ({
  queryKey: ["epics", "summary"],
  queryFn: async () => {
    const timeDifference = +new Date() - timestarted;
    console.log("Running api/epics/overview query at", timeDifference);
    const epicsOverview = await fetchJson<EpicOverview[]>("api/epics/overview");
    return { epicsOverview };
  },
  staleTime: 1000 * 60 * 5,
  gcTime: 1000 * 60 * 5,
});

A query key, and function, and some cache settings. I’m passing in the timestarted value from context, so we can see when these queries fire. This will help us detect waterfalls.

Let’s look at the root epics page (with a few details removed for space).

type SearchParams = {
  page: number;
};

export const Route = createFileRoute("/app/epics/")({
  validateSearch(search: Record<string, unknown>): SearchParams {
    return {
      page: parseInt(search.page as string, 10) || 1,
    };
  },
  loaderDeps: ({ search }) => {
    return { page: search.page };
  },
  component: Index,
  pendingComponent: () => <div>Loading epics ...</div>,
  pendingMinMs: 3000,
  pendingMs: 10,
});

function Index() {
  const context = Route.useRouteContext();
  const { page } = Route.useSearch();

  const { data: epicsData } = useSuspenseQuery(epicsQueryOptions(context.timestarted, page));
  const { data: epicsCount } = useSuspenseQuery(epicsCountQueryOptions(context.timestarted));

  return (
    <div className="p-3">
      <h3>Epics page!</h3>
      <h3>There are {epicsCount.count} epics</h3>
      <div>
        {epicsData.map((e, idx) => (
          <Fragment key={idx}>
            <div>{e.name}</div>
          </Fragment>
        ))}
        <div className="flex gap-3">
          <Link to="/app/epics" search={{ page: page - 1 }} disabled={page === 1}>
            Prev
          </Link>
          <Link to="/app/epics" search={{ page: page + 1 }} disabled={!epicsData.length}>
            Next
          </Link>
        </div>
      </div>
    </div>
  );
}

Two queries on this page: one to get the list of (paged) epics, another to get the total count of all the epics. Let’s run it

It’s as silly as before, but it does show the three pieces of data we’ve fetched: the overview data we fetched in the epics layout; and then the count of epics, and the list of epics we loaded in the epics page beneath that.

What’s more, when we run this, we first see the pending component for our root route. That resolves quickly, and shows the main navigation, along with the pending component for our epics route. That resolves, showing the epics overview data, and then revealing the pending component for our epics page, which eventually resolves and shows the list and count of our epics.

Our component-level data fetching is working, and integrating, via Suspense, with the same Router pending components we already had. Very cool!

Let’s take a peak at our console though, and look at all the various logging we’ve been doing, to track when these fetches happen

The results are… awful. Component-level data fetching with Suspense feels really good, but if you’re not careful, these waterfalls are extremely easy to create. The problem is, when a component suspends while waiting for data, it prevents its children from rendering. This is precisely what’s happening here. The route is suspending, and not even giving the child component, which includes the page (and any other nested route components underneath) from rendering, which prevents those components’ fetches from starting.

There’s two potential solutions here: we could dump Suspense, and use the useQuery hook, instead, which does not suspend. That would require us to manually track multiple isLoading states (for each useQuery hook), and coordinate loading UX to go with that. For the epics page, we’d need to track both the count loading state, and the epics list state, and not show our UI until both have returned. And so on, for every other page.

The other solution is to start pre-fetching these queries sooner.

We’ll go with option 2.

Prefetching

Remember previously we saw that loader functions all run in parallel. This is the perfect opportunity to start these queries off ahead of time, before the components even render. TanStack Query gives us an API to do just that.

To prefetch with Query, we take the queryClient object we saw before, and call queryClient.prefetchQuery and pass in the exact same query options and Query will be smart enough, when the component loads and executes useSuspenseQuery, to see that the query is already in flight, and just latch onto that same request. That’s also a big reason why we put those query options into the epicsSummaryQueryOptions helper function: to make it easier to reuse in the loader, to prefetch.

Here’s the loader we’ll add to the epics route:

loader({ context }) {
  const queryClient = context.queryClient;
  queryClient.prefetchQuery(epicsSummaryQueryOptions(context.timestarted));
},

The loader receives the route tree’s context, from which it grabs the queryClient. From there, we call prefetchQuery and pass in the same options.

Let’s move on to the Epics page. To review, this is the relevant code from our Epics page:

function Index() {
  const context = Route.useRouteContext();
  const { page } = Route.useSearch();

  const { data: epicsData } = useSuspenseQuery(epicsQueryOptions(context.timestarted, page));
  const { data: epicsCount } = useSuspenseQuery(epicsCountQueryOptions(context.timestarted));
  
  // ..

We grab the current page from the URL, and the context, for the timestarted value. Now let’s do the same thing we just did, and repeat this code in the loader, to prefetch.

async loader({ context, deps }) {
  const queryClient = context.queryClient;

  queryClient.prefetchQuery(epicsQueryOptions(context.timestarted, deps.page));
  queryClient.prefetchQuery(epicsCountQueryOptions(context.timestarted));
},

Now when we check the console, we see something a lot nicer.

Fetching state

What happens when we page up. The page value will change in the URL, Router will send a new page value down into our loader, and our component. Then, our useSuspenseQuery will execute with new query values, and suspend again. That means our existing list of tasks will disappear, and show the “loading tasks” pending component. That would be a terrible UX.

Fortunately, React offers us a nice solution, with the useDeferredValue hook. The docs are here. This allows us to “defer” a state change. If a state change causes our deferred value on the page to suspend, React will keep the existing UI in place, and the deferred value will simply hold the old value. Let’s see it in action.

function Index() {
  const { page } = Route.useSearch();
  const context = Route.useRouteContext();

  const deferredPage = useDeferredValue(page);
  const loading = page !== deferredPage;

  const { data: epicsData } = useSuspenseQuery(
    epicsQueryOptions(context.timestarted, deferredPage)
  );
  const { data: epicsCount } = useSuspenseQuery(
    epicsCountQueryOptions(context.timestarted)
  );
 
  // ...

We wrap the changing page value in useDeferredValue, and just like that, our page does not suspend when the new query is in flight. And to detect that a new query is running, we compare the real, correct page value, with the deferredPage value. If they’re different, we know new data are loading, and we can display a loading spinner (or in this case, put an opacity overlay on the epics list)

Queries are re-used!

When using react-query for data management, we can now re-use the same query across different routes. Both the view epic and edit epic pages need to fetch info on the epic the user is about to view, or edit. Now we can define those options in one place, like we had before.

export const epicQueryOptions = (timestarted: number, id: string) => ({
  queryKey: ["epic", id],
  queryFn: async () => {
    const timeDifference = +new Date() - timestarted;

    console.log(`Loading api/epic/${id} data at`, timeDifference);
    const epic = await fetchJson<Epic>(`api/epics/${id}`);
    return epic;
  },
  staleTime: 1000 * 60 * 5,
  gcTime: 1000 * 60 * 5,
});

We can use them in both routes, and have them be cached in between (assuming we set the caching values to allow that). You can try it in the demo app: view an epic, go back to the list, then edit the same epic (or vice versa). Only the first of those pages you visit should cause the fetch to happen in your network tab.

Updating with react-query

Just like with tasks, epics have a page where we can edit an individual epic. Let’s see what the saving logic looks like with react-query.

Let’s quickly review the query keys for the epics queries we’ve seen so far. For an individual epic, it was:

export const epicQueryOptions = (timestarted: number, id: string) => ({
  queryKey: ["epic", id],

For the epics list, it was this:

export const epicsQueryOptions = (timestarted: number, page: number) => ({
  queryKey: ["epics", "list", page],

And the count:

export const epicsCountQueryOptions = (timestarted: number) => ({
  queryKey: ["epics", "count"],

Finally, the epics overview:

export const epicsSummaryQueryOptions = (timestarted: number) => ({
  queryKey: ["epics", "summary"],

Notice the pattern: epics followed by various things for the queries that affected multiple epics, and for an individual epic, we did ['epic', ${epicId}]. With that in mind, let’s see just how easy it is to invalidate these queries after a mutation:

const save = async () => {
  setSaving(true);
  await postToApi("api/epic/update", {
    id: epic.id,
    name: newName.current!.value,
  });

  queryClient.removeQueries({ queryKey: ["epics"] });
  queryClient.removeQueries({ queryKey: ["epic", epicId] });

  navigate({ to: "/app/epics", search: { page: 1 } });

  setSaving(false);
};

The magic is on the highlighted lines.

With one fell sweep, we remove all cached entries for any query that started with epics, or started with ['epic', ${epicId}], and Query will handle the rest. Now, when we navigate back to the epics page (or any page that used these queries), we’ll see the suspense boundary show, while fresh data are loaded. If you’d prefer to keep stale data on the screen, while the fresh data load, that’s fine too: just use queryClient.invalidateQueries instead. If you’d like to detect if a query is re-fetching in the background, so you can display an inline spinner, use the isFetching property returned from useSuspenseQuery.

const { data: epicsData, isFetching } = useSuspenseQuery(
  epicsQueryOptions(context.timestarted, deferredPage)
);

Odds and ends

We’ve gone pretty deep on TanStack Route and Query. Let’s take a look at one last trick.

If you recall, we saw that pending components ship a related pendingMinMs that forced a pending component to stay on the page a minimum amount of time, even if the data were ready. This was to avoid a jarring flash of a loading state. We also saw that TanStack Router uses Suspense to show those pending components, which means that react-query’s useSuspenseQuery will seamlessly integrate with it. Well, almost seamlessly. Router can only use the pendingMinMs value with the promise we return from the Router’s loader. But now we don’t really return any promise from the loader; we prefetch some stuff, and rely on component-level data fetching to do the real work.

Well there’s nothing stopping you from doing both! Right now our loader looks like this:

async loader({ context, deps }) {
  const queryClient = context.queryClient;

  queryClient.prefetchQuery(epicsQueryOptions(context.timestarted, deps.page));
  queryClient.prefetchQuery(epicsCountQueryOptions(context.timestarted));
},

Query also ships with a queryClient.ensureQueryData method, which can load query data, and return a promise for that request. Let’s put it to good use so we can use pendingMinMs again.

One thing you do not want to do is this:

await queryClient.ensureQueryData(epicsQueryOptions(context.timestarted, deps.page)),
await queryClient.ensureQueryData(epicsCountQueryOptions(context.timestarted)),

That will block on each request, serially. In other words, a waterfall. Instead, to kick off both requests immediately and wait on them in the loader (without a waterfall), you can do this:

await Promise.allSettled([
  queryClient.ensureQueryData(epicsQueryOptions(context.timestarted, deps.page)),
  queryClient.ensureQueryData(epicsCountQueryOptions(context.timestarted)),
]);

Which works, and keeps the pending component on the screen for the duration of pendingMinMs

You won’t always, or even usually need to do this. But it’s handy for when you do.

Wrapping up

This has been a whirlwind route of TanStack Router and TanStack Query, but hopefully not an overwhelming one. These tools are incredibly powerful, and offer the ability to do just about anything. I hope this post will help some people put them to good use!

Article Series

]]>
https://frontendmasters.com/blog/tanstack-router-data-loading-2/feed/ 2 4492
Loading Data with TanStack Router: Getting Going https://frontendmasters.com/blog/tanstack-router-data-loading-1/ https://frontendmasters.com/blog/tanstack-router-data-loading-1/#respond Wed, 20 Nov 2024 18:52:19 +0000 https://frontendmasters.com/blog/?p=4465 TanStack Router is one of the most exciting projects in the web development ecosystem right now, and it doesn’t get nearly enough attention. It’s a fully fledged client-side application framework that supports advanced routing, nested layouts, and hooks for loading data. Best of all, it does all of this with deep type safety.

Article Series

This post is all about data loading. We’ll cover the built-in hooks TanStack Router ships with to load and invalidate data. Then we’ll cover how easily TanStack Query (also known as react-query) integrates and see what the tradeoffs of each are.

The code for everything we’re covering is in this GitHub repo. As before, I’m building an extremely austere, imaginary Jira knockoff. There’s nothing useful in that repo beyond the bare minimum needed for us to take a close look at how data loading works. If you’re building your own thing, be sure to check out the DevTools for TanStack Router. They’re outstanding.

The app does load actual data via SQLite, along with some forced delays, so we can more clearly see (and fix) network waterfalls. If you want to run the project, clone it, run npm i, and then open two terminals. In the first, run npm run server, which will create the SQLite database, seed it with data, and set up the API endpoints to fetch, and update data. In the second, run npm run dev to start the main project, which will be on http://localhost:5173/. There are some (extremely basic) features to edit data. If at any point you want to reset the data, just reset the server task in your terminal.

The app is contrived. It exists to show Router’s capabilities. We’ll often have odd use cases, and frankly questionable design decisions. This was purposeful, in order to simulate real-world data loading scenarios, without needing a real-world application.

But what about SSR?

Router is essentially a client-side framework. There are hooks to get SSR working, but they’re very much DIY. If this disappoints you, I’d urge just a bit of patience. TanStack Start (now in Beta) is a new project that, for all intents and purposes, adds SSR capabilities to the very same TanStack Router we’ll be talking about. What makes me especially excited about TanStack Start is that it adds these server-side capabilities in a very non-intrusive way, which does not change or invalidate anything we’ll be talking about in this post (or talked about in my last post on Router, linked above). If that’s not entirely clear and you’d like to learn more, stay tuned for my future post on TanStack Start.

The plan

TanStack Router is an entire application framework. You could teach an entire course on it, and indeed there’s no shortage of YouTube videos out there. This blog will turn into a book if we try to cover each and every option in depth.

In this post we’ll cover the most relevant features and show code snippets where helpful. Refer to the docs for details. Also check out the repo for this post as all the examples we use in this post are fleshed out in their entirety there.

Don’t let the extremely wide range of features scare you. The vast majority of the time, some basic loaders will get you exactly what you need. We’ll cover some of the advanced features, too, so you know they’re there, if you ever do need them.

Starting at the top: context

When we create our router, we can give it “context.” This is global state. For our project, we’ll pass in our queryClient for react-query (which we’ll be using a little later). Passing the context in looks like this:

// main.tsx
import { createRouter } from "@tanstack/react-router";

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

const queryClient = new QueryClient();

// Import the generated route tree
import { routeTree } from "./routeTree.gen";

const router = createRouter({ 
  routeTree, 
  context: { queryClient } 
});

Then we’ll make sure Router integrates what we put on context into the static types. We do this by creating our root route like this:

// routes/__root.tsx
export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: Root,
});

This context will be available to all routes in the tree, and inside API hooks like loader, which we’ll get to shortly.

Adding to context

Context can change. We set up truly global context when we start Router up at our application’s root, but different locations in the route tree can add new things to context, which will be visible from there, downward in the tree. There’s two places for this, the beforeLoad function, and the context function. Yes: route’s can take a context function which modifies the route tree’s context value.

beforeLoad

The beforeLoad method runs always, on each active route, anytime the URL changes in any way. This is a good place to check preconditions and redirect. If you return a value from here, that value will be merged into the router’s context, and visible from that route downward. This function blocks all loaders from running, so be extremely careful what you do in here. Data loading should generally be avoided unless absolutely needed, since any loaders will wait until this function is complete, potentially creating waterfalls.

Here’s a good example of what to avoid, with an opportunity to see why. This beforeLoad fetches the current user, places it into context, and does a redirect if there is no user.

// routes/index.tsx
export const Route = createFileRoute("/")({
  async beforeLoad() {
    const user = await getCurrentUser();
    if (!user) {
      throw redirect({
        to: "/login",
      });
    }
    document.cookie = `user=${user.id};path=/;max-age=31536000`;

    return { user };
  },

  // ...

We’ll be looking at some data loading in a bit, and measure what starts when. You can go into the getCurrentUser function and uncomment the artificial delay in there, and see it block everything. This is especially obvious if you’re running Router’s DevTools. You’ll see this path block, and only once ready, allow all loaders below to execute.

But this is a good enough example to show how this works. The user object is now in context, visible to routes beneath it.

A more realistic example would be to check for a logged-in cookie, optimistically assume the user is logged in, and rely on network calls we do in the loaders to detect a logged-out user, and redirect accordingly. To make things even more realistic, those loaders for the initial render would run on the server, and figure out if a user is actually logged out before we show the user anything; but that will wait for a future post on TanStack Start.

What we have is sufficient to show how the beforeLoad callback works.

Context (function)

There’s also a context function we can provide routes. This is a non-async function that also gives us an opportunity to add to context. But it runs much more conservatively. This function only runs when the URL changes in a way that’s relevant to that route. So for a route of, say, app/epics/$epicId, the context function will re-run when the epicId param changes. This might seem strange, but it’s useful for modifying the context, but only when the route has changed, especially when you need to put non-primitive values (objects and functions) onto context. These non-primitive values are always compared by reference, and therefore always unique against the last value generated. As a result, they would cause render churning if added in beforeLoad, since React would (incorrectly) think it needed to re-render a route when nothing had changed.

For now, here’s some code in our root route to mark the time for when the initial render happens, so we can compare that to the timestamp of when various queries run in our tree. This will help us see, and fix network waterfalls.

// routes/__root.tsx
export const Route = createRootRouteWithContext<MyRouterContext>()({
  context({ location }) {
    const timeStarted = +new Date();
    console.log("");
    console.log("Fresh navigation to", location.href);
    console.log("-------------------");

    return { timestarted: timeStarted };
  },

  // ...

This code is in our root route, so it will never re-run, since there’s no path parameters the root route depends on.

Now everywhere in our route tree will have a timestarted value that we can use to detect any delays from data fetches in our tree.

Loaders

Let’s actually load some data. Router provides a loader function for this. Any of our route configurations can accept a loader function, which we can use to load data. Loaders all run in parallel. It would be bad if a layout needed to complete loading its data before the path beneath it started. Loaders receive any path params on the route’s URL, any search params (querystring values) the route has subscribed to, the context, and a few other goodies, and loads whatever data it needs. Router will detect what you return, and allow components to retrieve that data via the useLoaderData hook — strongly typed.

Loader in a route

Let’s take a look at tasks.route.tsx.

This is a route that will run for any URL at all starting with /app/tasks. It will run for that path, for /app/tasks/$taskId, for app/tasks/$taskId/edit, and so on.

export const Route = createFileRoute("/app/tasks")({
  component: TasksLayout,
  loader: async ({ context }) => {
    const now = +new Date();
    console.log(`/tasks route loader. Loading task layout info at + ${now - context.timestarted}ms since start`);

    const tasksOverview = await fetchJson<TaskOverview[]>("api/tasks/overview");
    return { tasksOverview };
  },
  gcTime: 1000 * 60 * 5,
  staleTime: 1000 * 60 * 2,
});

We receive the context, and grab the timestarted value from it. We request some overview data on our tasks, and send that data down.

The gcTime property controls how long old route data are kept in cache. So if we browse from tasks over to epics, and then come back in 5 minutes and 1 second, nothing will be there, and the page will load in fresh. staleTime controls how long a cached entry is considered “fresh.” This determines whether cached data are refetched in the background. Here it’s set to two minutes. This means if the user hits this page, then goes to the epics page, waits 3 minutes, then browses back to tasks, the cached data will show, while the tasks data is re-fetched in the background, and (if changed) update the UI.

You’re probably wondering if TanStack Router tells you this background re-fetch is happening, so you can show an inline spinner, and yes, you can detect this like so:

const { isFetching } = Route.useMatch();

Loader in a page

Now let’s take a look at the tasks page.

export const Route = createFileRoute("/app/tasks/")({
  component: Index,
  loader: async ({ context }) => {
    const now = +new Date();
    console.log(`/tasks/index path loader. Loading tasks at + ${now - context.timestarted}ms since start`);

    const tasks = await fetchJson<Task[]>("api/tasks");
    return { tasks };
  },
  gcTime: 1000 * 60 * 5,
  staleTime: 1000 * 60 * 2,
  pendingComponent: () => <div>Loading tasks list...</div>,
  pendingMs: 150,
  pendingMinMs: 200,
});

This is the route for the specific URL /app/tasks. If the user were to browse to /app/tasks/$taskId then this component would not run. This is a specific page, not a layout (which Router calls a “route”). Basically the same as before, except now we’re loading the list of tasks to display on this page.

We’ve added some new properties this time, though. The pendingComponent property allows us to render some content while the loader is working. We also specified pendingMs, which controls how long we wait before showing the pending component. Lastly, pendingMinMs allows us to force the pending component to stay on the screen for a specified amount of time, even if the data are ready. This can be useful to avoid a brief flash of a loading component, which can be jarring to the user.

If you’re wondering why we’d even want to use pendingMs to delay a loading screen, it’s for subsequent navigations. Rather than immediately transition from the current page to a new page’s loading component, this setting lets us stay on the current page for a moment, in the hopes that the new page will be ready quickly enough that we don’t have to show any pending component at all. Of course, on the initial load, when the web app first starts up, these pendingComponents do show immediately, as you’d expect.

Let’s run our tasks page.

It’s ugly, and frankly useless, but it works. Now let’s take a closer look.

Loaders running in parallel

If we peak in our console, we should see something like this:

If you have DevTools open, you should see something like below. Note how the route and page load and finish in parallel.

As we can see, these requests started a mere millisecond apart from each other, since the loaders are running in parallel (since this isn’t the real Jira, I had to manually add a delay of 750ms to each of the API endpoints).

Different routes using the same data

If we look at the loader for the app/tasks/$taskId route, and the loader to the app/tasks/$taskId/edit route, we see the same fetch call:

const task = await fetchJson<Task>(`api/tasks/${taskId}`);

This is because we need to load the actual task in order to display it, or in order to display it in a form for the user to make changes. Unfortunately though, if you click the edit button for any task, then go back to the tasks list (without saving anything), then click the edit button for the same task, you should notice the same exact data being requested. This makes sense. Both loaders happen to make the same fetch() call, but there’s nothing in our client to cache the call. This is probably fine 99% of the time, but this is one of the many things react-query will improve for us, in a bit.

Updating data

If you click the edit button for any task, you should be brought to a page with an extremely basic form that will let you edit the task’s name. Once we click save, we want to navigate back to the tasks list, but most importantly, we need to tell Router that we’ve changed some data, and that it will need to invalidate some cached entries, and re-fetch when we go back to those routes.

This is where Router’s built-in capabilities start to get stretched, and where we might start to want react-query (discussed in part 2 of this post). Router will absolutely let you invalidate routes, to force re-fetches. But the API is fairly simple, and fine-grained. We basically have to describe each route we want invalidated (or removed). Let’s take a look:

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

// ...

const router = useRouter();
const save = async () => {
  await postToApi("api/task/update", {
    id: task.id,
    title: newTitleEl.current!.value,
  });

  router.invalidate({
    filter: route => {
      return (
        route.routeId == "/app/tasks/" ||
        (route.routeId === "/app/tasks/$taskId/" && route.params.taskId === taskId) ||
        (route.routeId === "/app/tasks_/$taskId/edit" && route.params.taskId === taskId)
      );
    },
  });

  navigate({ to: "/app/tasks" });
};

Note the call to router.invalidate. This tells Router to mark any cached entries matching that filter as stale, causing us to re-fetch them the next time we browse to those paths. We could also pass absolutely nothing to that same invalidate method, which would tell Router to invalidate everything.

Here we invalidated the main tasks list, as well as the view and edit pages, for the individual task we just modified.

Now when we navigate back to the main tasks page we’ll immediately see the prior, now-stale data, but new data will fetch, and update the UI when present. Recall that we can use const { isFetching } = Route.useMatch(); to show an inline spinner while this fetch is happening.

If you’d prefer to completely remove the cache entries, and have the task page’s “Loading” component show, then you can use router.clearCache instead, with the same exact filter argument. That will remove those cache entries completely, forcing Router to completely re-fetch them, and show the pending component. This is because there is no longer any stale data left in the cache; clearCache removed it.

There is one small caveat though: Router will prevent you from clearing the cache for the page you’re on. That means we can’t clear the cache for the edit task page, since we’re sitting on it already. To be clear, when we call clearCache, the filter function won’t even look at the route you’re on; the ability to remove it simply does not exist.

Instead, you could do something like this:

router.clearCache({
  filter: route => {
    return route.routeId == "/app/tasks/" || (route.routeId === "/app/tasks_/$taskId/edit" && route.params.taskId === taskId);
  },
});

router.invalidate({
  filter: route => {
    return route.routeId === "/app/tasks_/$taskId/edit" && route.params.taskId === taskId;
  },
});

But really, at this point you should probably be looking to use react-query, which we’ll cover in the next post.

Article Series

]]>
https://frontendmasters.com/blog/tanstack-router-data-loading-1/feed/ 0 4465
Introducing TanStack Router https://frontendmasters.com/blog/introducing-tanstack-router/ https://frontendmasters.com/blog/introducing-tanstack-router/#comments Fri, 13 Sep 2024 16:16:57 +0000 https://frontendmasters.com/blog/?p=3821 TanStack Router is an incredibly exciting project. It’s essentially a fully-featured client-side JavaScript application framework. It provides a mature routing and navigation system with nested layouts and efficient data loading capabilities at every point in the route tree. Best of all, it does all of this in a type-safe manner.

What’s especially exciting is that, as of this writing, there’s a TanStack Start in the works, which will add server-side capabilities to Router, enabling you to build full-stack web applications. Start promises to do this with a server layer applied directly on top of the same TanStack Router we’ll be covering here. That makes this a perfect time to get to know Router if you haven’t already.

TanStack Router is more than just a router — it’s a full-fledged client-side application framework. So to prevent this post from getting too long, we won’t even try to cover it all. We’ll limit ourselves to routing and navigation, which is a larger topic than you might think, especially considering the type-safe nature of Router.

Article Series

Getting started

There are official TanStack Router docs and a quickstart guide, which has a nice tool for scaffolding a fresh Router project. You can also clone the repo used for this post and follow along.

The Plan

In order to see what Router can do and how it works, we’ll pretend to build a task management system, like Jira. Like the real Jira, we won’t make any effort at making things look nice or be pleasant to use. Our goal is to see what Router can do, not build a useful web application.

We’ll cover: routing, layouts, paths, search parameters, and of course static typing all along the way.

Let’s start at the very top.

The Root Route

This is our root layout, which Router calls __root.tsx. If you’re following along on your own project, this will go directly under the routes folder.

import { createRootRoute, Link, Outlet } from "@tanstack/react-router";

export const Route = createRootRoute({
  component: () => {
    return (
      <>
        <div>
          <Link to="/">
            Home
          </Link>
          <Link to="/tasks">
            Tasks
          </Link>
          <Link to="/epics">
            Epics
          </Link>
        </div>
        <hr />
        <div>
          <Outlet />
        </div>
      </>
    );
  },
});

The createRootRoute function does what it says. The <Link /> component is also fairly self-explanatory (it makes links). Router is kind enough to add an active class to Links which are currently active, which makes it easy to style them accordingly (as well as adds an appropriate aria-current="page" attribute/value). Lastly, the <Outlet /> component is interesting: this is how we tell Router where to render the “content” for this layout.

Running the App

We run our app with npm run dev. Check your terminal for the port on localhost where it’s running.

More importantly, the dev watch process monitors the routes we’ll be adding, and maintains a routeTree.gen.ts file. This syncs metadata about our routes in order to help build static types, which will help us work with our routes safely. Speaking of, if you’re building this from scratch from our demo repo, you might have noticed some TypeScript errors on our Link tags, since those URLs don’t yet exist. That’s right: TanStack Router deeply integrates TypeScript into the route level, and will even validate that your Link tags are pointing somewhere valid.

To be clear, this is not because of any editor plugins. The TypeScript integration itself is producing errors, as it would in your CI/CD system.

src/routes/\_\_root.tsx:8:17 - error TS2322: Type '"/"' is not assignable to type '"." | ".." | undefined'.
          <Link to="/" className="[&.active]:font-bold">

Building the App

Let’s get started by adding our root page. In Router, we use the file index.tsx to represent the root / path, wherever we are in the route tree (which we’ll explain shortly). We’ll create index.tsx, and, assuming you have the dev task running, it should scaffold some code for you that looks like this:

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

export const Route = createFileRoute("/")({
  component: () => <div>Hello /!</div>,
});

There’s a bit more boilerplate than you might be used to with metaframeworks like Next or SvelteKit. In those frameworks, you just export default a React component, or plop down a normal Svelte component and everything just works. In TanStack Router we have have to call a function called createFileRoute, and pass in the route to where we are.

The route is necessary for the type safety Router has, but don’t worry, you don’t have to manage this yourself. The dev process not only scaffolds code like this for new files, it also keeps those path values in sync for you. Try it — change that path to something else, and save the file; it should change it right back, for you. Or create a folder called junk and drag it there: it should change the path to "/junk/".

Let’s add the following content (after moving it back out of the junk folder).

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

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

function Index() {
  return (
    <div>
      <h3>Top level index page</h3>
    </div>
  );
}

Simple and humble — just a component telling us we’re in the top level index page.

Routes

Let’s start to create some actual routes. Our root layout indicated we want to have paths for dealing with tasks and epics. Router (by default) uses file-based routing, but provides you two ways to do so, which can be mixed and matched (we’ll look at both). You can stack your files into folders which match the path you’re browsing. Or you can use “flat routes” and indicate these route hierarchies in individual filenames, separating the paths with dots. If you’re thinking only the former is useful, stay tuned.

Just for fun, let’s start with the flat routes. Let’s create a tasks.index.tsx file. This is the same as creating an index.tsx inside of an hypothetical tasks folder. For content we’ll add some basic markup (we’re trying to see how Router works, not build an actual todo app).

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

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

function Index() {
  const tasks = [
    { id: "1", title: "Task 1" },
    { id: "2", title: "Task 2" },
    { id: "3", title: "Task 3" },
  ];

  return (
    <div>
      <h3>Tasks page!</h3>
      <div>
        {tasks.map((t, idx) => (
          <div key={idx}>
            <div>{t.title}</div>
            <Link to="/tasks/$taskId" params={{ taskId: t.id }}>
              View
            </Link>
            <Link to="/tasks/$taskId/edit" params={{ taskId: t.id }}>
              Edit
            </Link>
          </div>
        ))}
      </div>
    </div>
  );
}

Before we continue, let’s add a layout file for all of our tasks routes, housing some common content that will be present on all pages routed to under /tasks. If we had a tasks folder, we’d just throw a route.tsx file in there. Instead, we’ll add a tasks.route.tsx file. Since we’re using flat files, here, we can also just name it tasks.tsx. But I like keeping things consistent with directory-based files (which we’ll see in a bit), so I prefer tasks.route.tsx.

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

export const Route = createFileRoute("/tasks")({
  component: () => (
    <div>
      Tasks layout <Outlet />
    </div>
  ),
});

As always, don’t forget the <Outlet /> or else the actual content of that path will not render.

To repeat, xyz.route.tsx is a component that renders for the entire route, all the way down. It’s essentially a layout, but Router calls them routes. And xyz.index.tsx is the file for the individual path at xyz.

This renders. There’s not much to look at, but take a quick look before we make one interesting change.

Notice the navigation links from the root layout at the very top. Below that, we see Tasks layout, from the tasks route file (essentially a layout). Below that, we have the content for our tasks page.

Path Parameters

The <Link> tags in the tasks index file give away where we’re headed, but let’s build paths to view, and edit tasks. We’ll create /tasks/123 and /tasks/123/edit paths, where of course 123 represents whatever the taskId is.

TanStack Router represents variables inside of a path as path parameters, and they’re represented as path segments that start with a dollar sign. So with that we’ll add tasks.$taskId.index.tsx and tasks.$taskId.edit.tsx. The former will route to /tasks/123 and the latter will route to /tasks/123/edit. Let’s take a look at tasks.$taskId.index.tsx and find out how we actually get the path parameter that’s passed in.

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

export const Route = createFileRoute("/tasks/$taskId/")({
  component: () => {
    const { taskId } = Route.useParams();

    return (
      <div>
        <div>
          <Link to="/tasks">Back</Link>
        </div>
        <div>View task {taskId}</div>
      </div>
    );
  },
});

The Route.useParams() object that exists on our Route object returns our parameters. But this isn’t interesting on its own; every routing framework has something like this. What’s particularly compelling is that this one is statically typed. Router is smart enough to know which parameters exist for that route (including parameters from higher up in the route, which we’ll see in a moment). That means that not only do we get auto complete…

…but if you put an invalid path param in there, you’ll get a TypeScript error.

We also saw this with the Link tags we used to navigate to these routes.

<Link to="/tasks/$taskId" params={{ taskId: t.id }}>

if we’d left off the params here (or specified anything other than taskId), we would have gotten an error.

Advanced Routing

Let’s start to lean on Router’s advanced routing rules (a little) and see some of the nice features it supports. I’ll stress, these are advanced features you won’t commonly use, but it’s nice to know they’re there.

The edit task route is essentially identical, except the path is different, and I put the text to say “Edit” instead of “View.” But let’s use this route to explore a TanStack Router feature we haven’t seen.

Conceptually we have two hierarchies: we have the URL path, and we have the component tree. So far, these things have lined up 1:1. The URL path:

/tasks/123/edit

Rendered:

root route -> tasks route layout -> edit task path

The URL hierarchy and the component hierarchy lined up perfectly. But they don’t have to.

Just for fun, to see how, let’s see how we can remove the main tasks layout file from the edit task route. So we want the /tasks/123/edit URL to render the same thing, but without the tasks.route.tsx route file being rendered. To do this, we just rename tasks.$taskId.edit.tsx to tasks_.$taskId.edit.tsx.

Note that tasks became tasks_. We do need tasks in there, where it is, so Router will know how to eventually find the edit.tsx file we’re rendering, based on the URL. But by naming it tasks_, we remove that component from the rendered component tree, even though tasks is still in the URL. Now when we render the edit task route, we get this:

Notice how Tasks layout is gone.

What if you wanted to do the opposite? What if you have a component hierarchy you want, that is, you want some layout to render in the edit task page, but you don’t want that layout to affect the URL. Well, just put the underscore on the opposite side. So we have tasks_.$taskId.edit.tsx which renders the task edit page, but without putting the tasks layout route into the component hierarchy. Let’s say we have a special layout we want to use only for task editing. Let’s create a _taskEdit.tsx.

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

export const Route = createFileRoute("/_taskEdit")({
  component: () => (
    <div>
      Special Task Edit Layout <Outlet />
    </div>
  ),
});

Then we change our task edit file to this _taskEdit.tasks_.$taskId.edit.tsx. And now when we browse to /tasks/1/edit we see the task edit page with our custom layout (which did not affect our URL).

Again, this is an advanced feature. Most of the time you’ll use simple, boring, predictable routing rules. But it’s nice to know these advanced features exist.

Directory-Based Routing

Instead of putting file hierarchies into file names with dots, you can also put them in directories. I usually prefer directories, but you can mix and match, and sometimes a judicious use of flat file names for things like pairs of $pathParam.index.tsx and $pathParam.edit.tsx feel natural inside of a directory. All the normal rules apply, so choose what feels best to you.

We won’t walk through everything for directories again. We’ll just take a peak at the finished product (which is also on GitHub). We have an epics path, which lists out, well, epics. For each, we can edit or view the epic. When viewing, we also show a (static) list of milestones in the epic, which we can also view or edit. Like before, for fun, when we edit a milestone, we’ll remove the milestones route layout.

So rather than epics.index.tsx and epics.route.tsx we have epics/index.tsx and epics/route.tsx. And so on. Again, they’re the same rules: replace the dots in the files names with slashes (and directories).

Before moving on, let’s briefly pause and look at the $milestoneId.index.tsx route. There’s a $milestoneId in the path, so we can find that path param. But look up, higher in the route tree. There’s also an $epicId param two layers higher. It should come as no surprise that Router is smart enough to realize this, and set the typings up such that both are present.

Type-Safe Querystrings

The cherry on the top of this post will be, in my opinion, one of the most obnoxious aspects of web development: dealing with search params (sometimes called querystrings). Basically the stuff that comes after the ? in a URL: /tasks?search=foo&status=open. The underlying platform primitive URLSearchParams can be tedious to work with, and frameworks don’t usually do much better, often providing you an un-typed bag of properties, and offering minimal help in constructing a new URL with new, updated querystring values.

TanStack Router provides a convenient, fully-featured mechanism for managing search params, which are also type-safe. Let’s dive in. We’ll take a high-level look, but the full docs are here.

We’ll add search param support for the /epics/$epicId/milestones route. We’ll allow various values in the search params that would allow the user to search milestones under a given epic. We’ve seen the createFileRoute function countless times. Typically we just pass a component to it.

export const Route = createFileRoute("/epics/$epicId/milestones/")({
  component: ({}) => {
    // ...

There’s lots of other functions it supports. For search params we want validateSearch. This is our opportunity to tell Router which search params this route supports, and how to validate what’s currently in the URL. After all, the user is free to type whatever they want into a URL, regardless of the TypeScript typings you set up. It’s your job to take potentially invalid values, and project them to something valid.

First, let’s define a type for our search params.

type SearchParams = {
  page: number;
  search: string;
  tags: string[];
};

Now let’s implement our validateSearch method. This receives a Record<string, unknown> representing whatever the user has in the URL, and from that, we return something matching our type. Let’s take a look.

export const Route = createFileRoute("/epics/$epicId/milestones/")({
  validateSearch(search: Record<string, unknown>): SearchParams {
    return {
      page: Number(search.page ?? "1") ?? 1,
      search: (search.search as string) || "",
      tags: Array.isArray(search.tags) ? search.tags : [],
    };
  },
  component: ({}) => {

Note that (unlike URLSearchParams) we are not limited to just string values. We can put objects or arrays in there, and TanStack will do the work of serializing and de-serializing it for us. Not only that, but you can even specify custom serialization mechanisms.

Moreover, for a production application, you’ll likely want to use a more serious validation mechanism, like Zod. In fact, Router has a number of adapters you can use out of the box, including Zod. Check out the docs on Search Params here.

Let’s manually browse to this path, without any search params, and see what happens. When we browse to

http://localhost:5173/epics/1/milestones

Router replaces (does not redirect) us to:

http://localhost:5173/epics/1/milestones?page=1&search=&tags=%5B%5D

TanStack ran our validation function, and then replaced our URL with the correct, valid search params. If you don’t like how it forces the URL to be “ugly” like that, stay tuned; there are workarounds. But first let’s work with what we have.

We’ve been using the Route.useParams method multiple times. There’s also a Route.useSearch that does the same thing, for search params. But let’s do something a little different. We’ve previously been putting everything in the same route file, so we could just directly reference the Route object from the same lexical scope. Let’s build a separate component to read, and update these search params.

I’ve added a MilestoneSearch.tsx component. You might think you could just import the Route object from the route file. But that’s dangerous. You’re likely to create a circular dependency, which might or might not work, depending on your bundler. Even if it “works” you might have some hidden issues lurking.

Fortunately Router gives you a direct API to handle this, getRouteApi, which is exported from @tanstack/react-router. We pass it a (statically typed) route, and it gives us back the correct route object.

const route = getRouteApi("/epics/$epicId/milestones/");

Now we can call useSearch on that route object and get our statically typed result.

We won’t belabor the form elements and click handlers to sync and gather new values for these search parameters. Let’s just assume we have some new values, and see how we set them. For this, we can use the useNavigate hook.

const navigate = useNavigate({ 
  from: "/epics/$epicId/milestones/"
});

We call it and tell it where we’re navigating from. Now we use the result and tell it where we want to go (the same place we are), and are given a search function from which we return the new search params. Naturally, TypeScript will yell at us if we leave anything off. As a convenience, Router will pass this search function the current values, making it easy to just add / override something. So to page up, we can do

navigate({
  to: ".",
  search: prev => {
    return { ...prev, page: prev.page + 1 };
  },
});

Naturally, there’s also a params prop to this function, if you’re browsing to a route with path parameters that you have to specify (or else TypeScript will yell at you, like always). We don’t need an $epicId path param here, since there’s already one on the route, and since we’re going to the same place we already are (as indicated by the from value in useNavigate, and the to: "." value in navigate function) Router knows to just keep what’s there, there.

If we want to set a search value and tags, we could do:

const newSearch = "Hello World";
const tags = ["tag 1", "tag 2"];

navigate({
  to: ".",
  search: prev => {
    return { page: 1, search: newSearch, tags };
  },
});

Which will make our URL look like this:

/epics/1/milestones?page=1&search=Hello%20World&tags=%5B"tag%201"%2C"tag%202"%5D

Again, the search, and the array of strings were serialized for us.

If we want to link to a page with search params, we specify those search params on the Link tag

<Link 
  to="/epics/$epicId/milestones" 
  params={{ epicId }} 
  search={{ search: "", page: 1, tags: [] }}>
  View milestones
</Link>

And as always, TypeScript will yell at us if we leave anything off. Strong typing is a good thing.

Making Our URL Prettier

As we saw, currently, browsing to:

http://localhost:5173/epics/1/milestones

Will replace the URL with this:

http://localhost:5173/epics/1/milestones?page=1&search=&tags=%5B%5D

It will have all those query params since we specifically told Router that our page will always have a page, search, and tags value. If you care about having a minimal and clean URL, and want that transformation to not happen, you have some options. We can make all of these values optional. In JavaScript (and TypeScript) a value does not exist if it holds the value undefined. So we could change our type to this:

type SearchParams = {
  page: number | undefined;
  search: string | undefined;
  tags: string[] | undefined;
};

Or this which is the same thing:

type SearchParams = Partial<{
  page: number;
  search: string;
  tags: string[];
}>;

Then do the extra work to put undefined values in place of default values:

validateSearch(search: Record<string, unknown>): SearchParams {
  const page = Number(search.page ?? "1") ?? 1;
  const searchVal = (search.search as string) || "";
  const tags = Array.isArray(search.tags) ? search.tags : [];

  return {
    page: page === 1 ? undefined : page,
    search: searchVal || undefined,
    tags: tags.length ? tags : undefined,
  };
},

This will complicate places where you use these values, since now they might be undefined. Our nice simple pageUp call now looks like this

navigate({
  to: ".",
  search: prev => {
    return { ...prev, page: (prev.page || 1) + 1 };
  },
});

On the plus side, our URL will now omit search params with default values, and for that matter, our <Link> tags to this page now don’t have to specify any search values, since they’re all optional.

Another Option

Router actually provides you another way to do this. Currently validateSearch accepts just an untyped Record<string, unknown> since the URL can contain anything. The “true” type of our search params is what we return from this function. Tweaking the return type is how we’ve been changing things.

But Router allows you to opt into another mode, where you can specify both a structure of incoming search params, with optional values, as well as the return type, which represents the validated, finalized type for the search params that will be used by your application code. Let’s see how.

First let’s specify two types for these search params

type SearchParams = {
  page: number;
  search: string;
  tags: string[];
};

type SearchParamsInput = Partial<{
  page: number;
  search: string;
  tags: string[];
}>;

Now let’s pull in SearchSchemaInput:

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

SearchSchemaInput is how we signal to Router that we want to specify different search params for what we’ll receive compared to what we’ll produce. We do it by intersecting our desired input type with this type, like this:

validateSearch(search: SearchParamsInput & SearchSchemaInput): SearchParams {

Now we perform the same original validation we had before, to produce real values, and that’s that. We can now browse to our page with a <Link> tag, and specify no search params at all, and it’ll accept it and not modify the URL, while still producing the same strongly-typed search param values as before.

That said, when we update our URL, we can’t just “splat” all previous values, plus the value we’re setting, since those params will now have values, and therefore get updated into the URL. The GitHub repo has a branch called feature/optional-search-params-v2 showing this second approach.

Experiment and choose what works best for you and your use case.

Wrapping up

TanStack Router is an incredibly exciting project. It’s a superbly-made, flexible client-side framework that promises fantastic server-side integration in the near future.

We’ve barely scratched the surface. We just covered the absolute basics of type-safe navigation, layouts, path params, and search params, but know there is much more to know, particularly around data loading and the upcoming server integration.

Article Series

]]>
https://frontendmasters.com/blog/introducing-tanstack-router/feed/ 5 3821
Testing Types in TypeScript https://frontendmasters.com/blog/testing-types-in-typescript/ https://frontendmasters.com/blog/testing-types-in-typescript/#respond Tue, 04 Jun 2024 14:40:52 +0000 https://frontendmasters.com/blog/?p=2485 Say that 10 times fast.

As your TypeScript usage gets more advanced, it can be extremely helpful to have utilities around that test and verify your types. Like unit testing, but without needing to set up Jest, deal with mocking, etc. In this post, we’ll introduce this idea. Then we’ll dive deeply into one particular testing utility that’s surprisingly difficult to create: a type that checks whether two types are the same.

This post will cover some advanced corners of TypeScript you’re unlikely to need for regular application code. If you’re not a huge fan of TS, please understand that you probably won’t need the things in this post for your everyday work, which is fine. But if you’re writing or maintaining TypeScript libraries, or even library-like code in your team’s app, the things we discuss here might come in handy.

Type helpers

Consider this Expect helper:

type Expect<T extends true> = T;

This type demands you pass true into it. This seems silly, but stay with me.

Now imagine, for some reason, you have a helper for figuring out whether a type is some kind of array:

type IsArray<T> = T extends Array<any> ? true : false;

You’d like to verify that IsArray works properly. You could type:

type X = IsArray<number[]>;

Then mouse over the X and verify that it’s true, which it is. But we don’t settle for ad hoc testing like that with normal code, so why would we with our advanced types?

Why don’t we write this instead:

type X = Expect<IsArray<number[]>>;

If we had messed up our IsArray type, the line above would error out, which we can see by passing the wrong thing into it:

type Y = Expect<IsArray<number>>;
// error: Type 'false' does not satisfy the constraint 'true'.

Better yet, let’s create another helper:

type Not<T extends false> = true;

and now we can actually test the negative of our IsArray helper

type Y = Expect<Not<IsArray<number>>>;

Except these fake type names like X and Y will get annoying very quickly, so let’s do this instead

// ts-ignore just to ignore the unused warning - everything inside of Tests will still type check
// @ts-ignore
type Tests = [
  Expect<IsArray<number[]>>,
  Expect<Not<IsArray<number>>>
];

So far so good. We can put these tests for our types right in our application files if we want, or move them to a separate file; the types are all erased when we ship either way, so don’t worry about bundle size.

Getting serious

Our IsArray type was trivial, as were our tests. In real life, we’ll be writing types that do more interesting things, usually taking in one or more types, and creating something new. And to test those sorts of things, we’ll need to be able to verify that two types are identical.

For example, say you want to write a type that takes in a generic, and if that generic is a function, returns the parameters of that function, else returns never. Pretend the Parameters type is not built into TypeScript, and imagine you write this:

type ParametersOf<T> = T extends (...args: infer U) => any ? U : never;

Which we’d test like this:

// ts-ignore just to ignore the unused warning - everything inside of Tests will still type check
// @ts-ignore
type Tests = [
  Expect<TypesMatch<ParametersOf<(a: string) => void>, [string]>>,
  Expect<TypesMatch<ParametersOf<string>, never>>
];

Great. But how do you write TypesMatch?

That’s the subject of the entire rest of this post. Buckle up!

Type Equality

Checking type equality is surprisingly hard in TypeScript, and the obvious solution will fail for baffling reasons unless you understand conditional types. Let’s tackle that before moving on.

We’ll start with the most obvious, potential solution:

type TypesMatch<T, U> = T extends U ? true : false;

You can think of T extends U in the same way as with object-oriented inheritance: is T the same as, or a sub-type of U. And instead of (just) object-oriented hierarchies, remember that you can have literal types in TypeScript. type Foo = "foo" is a perfectly valid type in TypeScript. It’s the type that represents all strings that match "foo". Similarly, type Foo = "foo" | "bar"; is the type representing all strings that match either "foo", or "bar". And literal types, and unions of literal types like that can both be thought of as sub-types of string, for these purposes.

Another (more common way) to think about this is that T extends U is true if T can be assigned to U, which makes sense; if T is the same, or a sub-type of U, then a variable of type T can be assigned to a variable of type U.

The obvious test works:

type Tests = [Expect<TypesMatch<string, string>>];

So far, so good. And:

type Tests = [Expect<Not<TypesMatch<string, "foo">>>];

This also works, since a variable of type string cannot be assigned to a variable of type "foo".

let foo: "foo" = "foo";
let str: string = "blah blah blah";

foo = str; // Error
// Type 'string' is not assignable to type '"foo"'.

But we hit problems with:

type Tests = [Expect<Not<TypesMatch<"foo", string>>>];

This fails. The string literal type "foo" is assignable to variables of type string.

Just test them both ways

I know what you’re thinking: just test it from both directions!

type TypesMatch<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false;

This solves both of our problems from above. Now both of these tests pass.

type Tests = [
  Expect<Not<TypesMatch<string, "foo">>>,
  Expect<Not<TypesMatch<"foo", string>>>,
];

Let’s try union types:

type Tests = [
  Expect<TypesMatch<string | number, string | number>>,
  Expect<Not<TypesMatch<string | number, string | number | boolean>>>
];

Both of these fail with:

Type ‘boolean’ does not satisfy the constraint ‘true’

Identical union types fail to match as identical, and different union types fail to match as different. What in the world is happening.

Conditional types over unions

So why did this not work with union types?

type TypesMatch<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false;

Let’s back up and try to simplify. Let’s imagine some simple (useless) types. Imagine a square and circle:

type Square = {
  length: number;
};
type Circle = {
  radius: number;
};

Now imagine a type that takes a generic in, and returns a description. If we pass in a Square, it returns the string literal type "4 Sides". If we pass in a circle, it returns the string literal "Round". And if we pass in anything else, it returns the string literal type "Dunno". This is very silly but just go with it.

type Description<T> = T extends Square
  ? "4 Sides"
  : T extends Circle
  ? "Round"
  : "Dunno";

Now imagine a function to use this type:

function getDescription<T>(obj: T): Description<T> {
  return null as any;
}
const s: Square = { length: 1 };
const c: Circle = { radius: 1 };

const sDescription = getDescription(s);
const cDescription = getDescription(c);

sDescription is of type "4 Sides" and cDescription is of type "Round". Nothing surprising. Now let’s consider a union type.

const either: Circle | Square = {} as any;
const eitherDescription = getDescription(either);

The type Circle | Square does not extend Square (a variable of type Circle | Square cannot be assigned to a variable of type Square) nor does it extend Circle. So we might naively expect eitherDescription to be "Dunno". But intuitively this feels wrong. either is a Circle or a Square, so the description should be either "4 Sides" or "Round".

And that’s exactly what it is:

Union Type

Distributing union types

When we have a generic type argument that’s also a type union, pushed across an extends check in a conditional type, the union itself is split up with each member of the union substituted into that check. TypeScript then takes every result, and unions them together. That union is the result of that extends operation.

Any never‘s are removed, as are any duplicates.

So for the above, we start with our type:

We substitute the union type that we passed in for T

Once our conditional type hits the extends keyword, if we passed in a union type, TypeScript will distribute over the union; it’ll run that ternary for each type in our union, and then union all of those results together. Square is first:

Square extends Square so "4 Sides" is the result. Then repeat with Circle:

Circle extends Circle so "Round" is the result of the second iteration. The two are union’d together, resulting in the "4 Sides" | "Round" that we just saw.

Playing on Hard Mode

Let’s take a fresh look at this:

type TypesMatch<T, U> = T extends U
  ? U extends T
    ? true
    : false
  : false;

Here’s what happens with:

TypesMatch<string | number, string | number>;

We start:

Then substitute string | number in for T and U. Evaluation starts, and immediately gets to our first extends:

T and U are both unions, but only the type before the extends is distributed. Let’s substitute string | number for U:

Now we’re ready to process that first extends. We’ll need to break up the T union, and run it for every type in that union. string is first:

string does extends string | number so we hit the true branch, and are immediately greeted by a second extends.

Think of it like a nested loop. We’ll process this inner extends in the same way. We’ll substitute each type in the U union, starting with string:

and of course string extends string is true. Our first result is true.

Now let’s continue processing our inner loop. U will next be numbernumber extends string is of course false, which is the second result of our type.

So far we have true | false. Now our outer loop continues. T moves on to become the second member of its union, numbernumber extends string | number is true:

so we again hit that first branch:

The inner loop starts all over again, with U first becoming string:

string extends number is false, so our overall result is true | false | falseU then becomes number:

which yields true.

The whole thing produced true | false | false | true. TypeScript removes the duplicates, leaving us with true | false.

Do you know what a simpler name for the type true | false is? It’s boolean. This type produced boolean, which is why we got the error:

Type ‘boolean’ does not satisfy the constraint ‘true’

We were expecting a literal type of true but got a union of true and false, which reduced to boolean.

It’s the same idea with:

type Result = TypesMatch<string | number, string | number | boolean>;

We won’t go through all the permutations. Suffice it to say we’ll get some mix of true and false, which will reduce back to boolean again.

So how to fix it?

We need to stop the union types from distributing. The distributing happens only with a raw generic type in a conditional type expression. We can turn it off by turning that type into another type, with the same assignability rules. A tuple type does that perfectly:

type TypesMatch<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;

Instead of asking if T extends U I ask if [T] extends [U][T] is a tuple type with one member, T. Think of it with non-generic types. [string] is essentially an array with a single element (and no more!) that’s a string.

All our normal rules apply. ["foo"] extends [string] is true, while [string] extends ["foo"] is false. You can assign a tuple with a single "foo" string literal to a tuple with a single string, for the same reason that you can assign a "foo" string literal to a variable of type string.

One last fix

We’re pretty much done, don’t worry.

type TypesMatch<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;

type Tests = [
  Expect<Not<TypesMatch<string, "foo">>>,
  Expect<TypesMatch<string | number, string | number>>,
  Expect<Not<TypesMatch<string | number, string | number | object>>>
];

This mostly works, but there’s one rub: optional properties. This test currently fails

Expect<{ a: number }, { a: number; b?: string }>;

Unfortunately both of those types are assignable to each other, which makes sense. The b in the second type is optional. { a: number; b?: string } can in fact be assigned to a variable expecting { a: number }, since the structure of { a: number } is satisfied. TypeScript is happy to ignore the extra properties. We’re really close though. This only happens if either type has optional properties which are absent from the other type.

This already fails:

Expect<{ a: number; b?: number }, { a: number; b?: string }>;

Those types are not at all compatible. So why don’t we just keep what we already have and verify that all of the property keys are the same. We’ll rename a smidge and wind up with this:

type ShapesMatch<T, U> = [T] extends [U]
  ? [U] extends [T]
    ? true
    : false
  : false;

type TypesMatch<T, U> = ShapesMatch<T, U> extends true
  ? ShapesMatch<keyof T, keyof U> extends true
    ? true
    : false
  : false;

What was TypesMatch is now ShapesMatch and our real TypesMatch calls that, then calls it again on the types’ keys. Don’t be scared of keyof T — that’s safe. This will work on primitive types, function types, etc. So long as the types are the same, the result will be the same. If the types are object types, they’ll match up those object properties.

Wrapping up

Conditional types can be incredibly helpful when you’re building advanced types for testing or otherwise. But they also come with some behaviors that can be surprising to the uninitiated. Hopefully this post made some of that clearer.

]]>
https://frontendmasters.com/blog/testing-types-in-typescript/feed/ 0 2485