Typing remix loaders with confidence

June 19, 2022 ・ 4 min read

Well, hello there, it’s time for a lightning-quick Remix tip. Let’s see how we can genuinely write typesafe Remix routes by sharing types between loaders and components for full-stack typing!

Remix, what?

For the readers unfamiliar with Remix, it’s a React framework created by the react-router team. It uses react-router to make a server-rendering full-stack framework with React support. It’s the other kind of Next.js (sorry).

Loaders, what are they?

Remix is a server-side rendering framework, and as such, you can load data directly to your components while they are being rendered on the server.

app/routes/index.tsx
export function loader() {
  return "Hello world!";
}

export default function SomeRemixPage() {
  const data = useLoaderData();
  return <p>{ data }</p>;
}

You can only define the loader function on the Remix route file, but you can then call the useLoaderData hook on every component used inside that route. This is very useful for better SEO and spares you from adding loading states to your app, as the page comes pre-rendered from the server.

Let’s add types the regular way

You can quickly type useLoaderData using type variables. Its type signature is useLoaderData<T>: T, so if you do useLoaderData<string>, you just typed your loader!

app/routes/index.tsx
export function loader(): string {
  return "Hello world!";
}

export default function SomeRemixPage() {
  const data = useLoaderData<string>();
  return <p>{ data }</p>;
}

However, this has a couple of issues. Typing the generic T on useLoaderData is the same thing as doing this:

const data = useLoaderData<string>();
const data = useLoaderData() as string;

If you do not type useLoaderData, its default type is any, so you can just cast that to whatever you want. This means that the following scenario won’t report type errors and would just crash during runtime.

app/routes/index.tsx
export function loader(): string {
  return "Hello world!";
}

export default function SomeRemixPage() {
  const { data } = useLoaderData<{data: string}>();
  return <p>{ data }</p>;
}

In the above scenario, this will crash, even though the types are all valid. We want the loader data to be of type { data: string }, but as there is no direct-type connection between the loader and the hook, some bugs might leak into runtime if you type all of your loader and useLoaderData like this.

Inferring the types from the loader

The solution is to infer the types from the loader automatically. The first step is to never use the LoaderFunction type.

import { json } from '@remix-run/node'; // or "@remix-run/cloudflare"
import type { LoaderFunction } from '@remix-run/node'; // or "@remix-run/cloudflare"

export const loader: LoaderFunction = async () => {
	return json({ ok: true });
};

As of Remix version 1.5.1 the LoaderFunction return type is Promise<Response> | Response | Promise<AppData> | AppData which means we cannot reliably use the solution I will propose. AppData is an internal Remix type that is the same as any, which doesn’t do much for type safety.

The second step is to never type the return value of the loader function. We are going to do that automatically from now on. So if you have any export function loader(): SomeType, make sure you remove the SomeType from there.

Then we can start inferring the type of our loader automatically!

type LoaderType = Awaited<ReturnType<typeof loader>>;

This essentially infers the type of the loader function.

  • Awaited extracts the type of a promise because loader can be async
  • ReturnType is pretty straightforward and returns the type returned by typeof loader

Revisiting our previous example, it would become this:

app/routes/index.tsx
export function loader(): string {
  return "Hello world!";
}

type LoaderType = Awaited<ReturnType<typeof loader>>;

export default function SomeRemixPage() {
  const { data } = useLoaderData<LoaderType>();
  return <p>{ data }</p>;
}

Typescript would then complain that there is no property data on type string! We can fix that by correctly refactoring our loader.

app/routes/index.tsx
export function loader() {
  return { data: "Hello world!" };
}

type LoaderType = Awaited<ReturnType<typeof loader>>;

export default function SomeRemixPage() {
  const { data } = useLoaderData<LoaderType>();
  return <p>{ data }</p>;
}

If you want to type the arguments of loader you can import the following from Remix internals:

import type { DataFunctionArgs } from "@remix-run/server-runtime";

export function loader(({ request }: DataFunctionArgs)) {
  // do stuff
}

This will keep the return type untouched so you can automatically infer it.

This solution is a great help because it also takes care of conditionals! Imagine that this page is only for authenticated users:

app/routes/index.tsx
export function loader({ request }: DataFunctionArgs) {
  if (!extractUserFromRequest(request)) return new Response(null, { status: 401 });
  return { data: "Hello world!" };
}

type LoaderType = Awaited<ReturnType<typeof loader>>;

export default function SomeRemixPage() {
  const { data } = useLoaderData<LoaderType>();
  return <p>{ data }</p>;
}

Here, Typescript would complain that there is no data on Response when using the useLoaderData hook. This would avoid a regression here. You could quickly fix this by using throw when checking for the user session instead of return. Remember that you can throw inside a loader function to immediately stop rendering! It would also keep Typescript silent because the function only returns { data: string }.

Final notes

You can also export the types from the inferred loader functions to use wherever you want. This lets us ensure everything is nice and tidy and keep runtime errors to a minimum.

Hope this helped, let me know if you have any questions! If you also have a better solution than this one, please let me know!

Stay safe out there!

See more posts

Want to talk about this? Feel free to reach me on the web: