A love letter to the Remix loader

A torn letter with a stamp that says United States postage 3 cents

This is an uncontroversial opinion for people using Remix, but it needs to be said:

  The loader API is the best thing about Remix.

Some background: A loader is a custom defined function that is responsible for "loading" all of the data that is required to render a route. It's often paired with a React component that I will refer to as the RouteComponent which is responsible for using that data to render a HTML page.

In Remix, by convention, both of these live in the same file in the app/routes directory. Remix auto discovers files in this directory to build up the supported url paths in an application based on their filename.

Love is simple

Let's start with the simplicity. The Remix loader is a function that takes a Request (and a few other arguments) and returns a promise that resolves to a Response. That's it!

The loader's primary job is to "load" all of the data required to render this route. Pretty good name huh? Let's look at a concrete example. Here is a simple loader that returns search results:

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const query = new URL(request.url).searchParams.get('s');
  const results = await searchService.search(query);
  return json({
    query,
    results,
  });
}

The result of the loader function is automatically made available to the route component and can be accessed using the useLoaderData hook.

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const query = new URL(request.url).searchParams.get('s');
  const results = await searchService.search(query);
  return json({
    query,
    results,
  });
}

export default function Search() {
  const {query, results} = useLoaderData<typeof loader>();
  return (
    <div>
      <h1>Search: {query}</h1>
      {results.map((result) => (
        <div key={result.id}>{result.name}</div>
      ))}
    </div>
  );
}

That is the gist of the loader function. Conceptually its pretty simple. One thing to know is the loader is only ever run on the server. On the initial server render, the response will be embedded in the HTML document. On the client, navigation changes in the browser, Remix will call the loader via fetch to avoid a full page reload.

Now let's look at some of the more powerful things we can do with it.

Love brings data together

Firstly, the loader is a great place to aggregate data from across multiple different sources. If we structure our code right we can even fetch data in parallel to speed up our response. The RouteComponent won't load until all of the data is resolved which can simplify some client scenarios since we don't have to worry about partial data.

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const query = new URL(request.url).searchParams.get('s');
  const results = searchService.search(query);
  const recommendations = recommendationsService.search(query);

  return json({
    query,
    results: await results,
    recommendations: await recommendations,
  });
}

The loader is also a great place to filter and process the data so we aren't bloating the network or client with irrelevant information.

Love finds its own logic in the language of the web

Since the loader's promise only needs to resolve to a Response object it's also great for doing some light business logic related to the web requests. This includes returning redirects, 404s, or other errors.

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const id = new URL(request.url).searchParams.get('id');
  const result = await entityService.find({id});
  if (result === null) {
    throw new Response(null, {
      status: 404,
      statusText: "Not Found",
    });
  }
  if (result.id !== id) {
    return redirect(`?id=${result.id}`);
  }
  return json({
    result,
  });
}

If our loader returns a rejected promise, Remix will recognize that as an error and render a special ErrorComponent instead of our RouteComponent. This lets us focus on handling the happy path in our RouteComponent.

Love is dynamic

So far we've only used the loader to pull down data for the initial page load. It may not be obvious but the loader is also a great place for dynamically loading data in response to user input. This is because by default Remix will re-run the loader every time our url changes.

This makes it a great place to load dynamic data in response to user input. All we have to do is tie the data to the url.

So next time we find ourselves reaching for a useEffect to fetch data from an API every-time the user changes some state.

export default function DateSelector() {
  const [date, setDate] = useState(null);
  const [results, setResults] = useState([]);
  useEffect(() => {
    fetch(`/myDataAPI?date=${date}`).then(r => r.json()).then(setResults);
  }, [date]);
  return (
    <>
      <DatePicker date={date} setDate={setDate} />
      <ResultsList results={results} />
    </>
  )
}

Instead try pushing some of that logic into the Remix loader.

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const date = new URL(request.url).searchParams.get('date');
  const results = await fetch(`/myDataAPI?date=${date}`).then(r => r.json());
  return json(results);
}

export default function DateSelector() {
  const date = new URLSearchParams(location.search).get('date');
  const results = useLoaderData();
  return (
    <>
      <DatePicker date={date} setDate={(newDate) => history.push(`?date=${newDate}`)} />
      <ResultsList results={results} />
    </>
  )
}

This change reduces the complexity of our <DateSelector /> component by pushing some of that complexity into Remix and our url. It also makes things easier to test since the loader is a simple Request -> Response function and our <DateSelector /> no longer has its own state.

This results in less work for us developers but as an added bonus we now have a better user experience for our customer since the state is now tied to the url, they can bookmark the page, share the link or just get back to where they are without extra hassle if their mobile phone unloads the tab.

In addition to url changes, Remix also re-runs the loader automatically after the user performs a <form> posts. This keeps our UI up to date with the latest data for free. Thanks loader! This behavior sounds simple but once you start using it you will be surprised at how much less often you find yourself reaching for a client side state management library like redux since the Remix loader handles the grunt work of syncing state with the server.

Love has a type

The decision to keep the loader function and RouteComponent in the same file creates some really great TypeScript support.  

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const query = new URL(request.url).searchParams.get('s');
  const results = await searchService.search(query);
  return json({
    query,
    results,
  });
}

export default function Search() {
  const {query, results} = useLoaderData<typeof loader>();
  return (<>...</>);
}

Look at that! It feels almost unfair that we can just add a typeof loader in the same file. No need to name a type. No need for an extra import/export. Things just work! Over the network! It almost feels unfair to other TypeScript code we write that Remix makes this so easy. Our editor/tools knows about any and all changes in the return value of the loader function.

Love's path may be parallel

In Remix, it is possible to have multiple routes active at one time when a UI is nested. This is useful when multiple pages share a common header or footer. When this happens, Remix will run the loader functions for each active route in parallel. This avoids accidentally introducing chained request sequences for independent data and ensures our user gets a quick response without requiring us to think about performance.

Love's pain, a bitter reminder of its depth

Whenever there is a super power it casts a shadow, and the Remix loader is absolutely a super power. But it does have its challenges.

Magic splits frontend code and backend code

The loader function runs on the server, but the RouteComponent runs on the client. For the developer, both of these functions live in the same file. The Remix compiler has to be pretty smart to figure out what code to pull into the client bundle and what should stay on the server.

Luckily, it works! And works surprisingly well! However, the complexity needed to make it work still makes me nervous. Is there a gap in all that complexity? Will Remix make a mistake one day and include something in a client bundle that it shouldn't?

A simpler model of all server code using a convention of ending with.server.ts  would give me more confidence, however it would also degrade Remix's great dev experience.

useLoaderData() is a bit clunky

This is mainly a complaint about testing. Since useLoaderData() is a hook and relies on Remix wiring up its engine around the component it can be a bit awkward to test a RouteComponent and inject some mocked data. It would be nicer if we had a different interface for injecting the server state into the client, but the hook is probably the most appropriate and familiar approach for React developers in 2023.

Easy to introduce waterfalls in async loader functions

If we naively add await to every function call that returns a promise it's a bit too easy to accidentally introduce a waterfall where we are waiting for one async request to complete before starting another async request in a sequence chain. This is a problem with all async functions and not a Remix specific issue. However, Remix could help address the issue by automatically unwrapping some promises and showcasing patterns for parallel loading in the loader documentation.

Love is gratitude in action

So thank you, humble Remix loader, for being the catalyst of my creativity, the backbone of my projects, and my only true love of the Remix API.

Thanks to Ingrid, Ryan Canulla and Clayton Phillips-Dorsett for feedback and for reading drafts.

Subscribe to Laconic Wit

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe