A love letter to the Remix loader
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.