There's a lot to love about Remix. I've written enough about that here and on X. But what I've found recently is Remix is a great choice for local-first apps. For the past 2 years, I've always reached for Remix for building web applications. So when I decided to revive Aurelius, a writing app that I built for myself, I wanted to make it local-first.
Local-first is a fancy way of saying that an application's data primarily lives on the user's browser and when needed, data can be synced to other devices and optionally a database in the cloud. There are plenty of advantages to building an app as local-first but I won't talk about them here. Enough has been said about that. What I will talk about is how I'm using Remix for building a local-first application.
When writing fullstack applications with any framework, there are patterns for loading and mutating data. I find Remix's patterns to be simpler compared to others. I just have to export a loader
function in my route and do all my data fetching there. I can even use my ORM directly in my loaders. For mutating data, I've to export an action
function in my route and handle form data and save it to the database.
This is all well and good when building fullstack applications with REST APIs and databases. But what if all the app's data lives on the browser? What if there is no REST API to call for loading and mutating data? I can't use loader
and action
functions now because they're run on the server. I have to resort to using useEffect
for loading and mutating data. I don't like useEffect
much. It's useful, sure, but it is also a footgun. And one of the things I like about Remix is how scarecely I reach for useEffect
.
In one of the recent versions, Remix introduced clientLoader
and clientAction
. And they're perfect for this use case! They're run on the client-side and when data is returned from these functions, I can use them in my component with useLoaderData
and useActionData
. Pretty much everything stays the same as using loader
and action
. Now my routes have data loading, mutations, and UI clearly separated and I love it!
Here's a clientLoader
from Aurelius' _index.tsx
route. I'm loading a getting started help article from a SQLite database that lives in the browser.
export const clientLoader = async () => { const helpArticle = await arls._help.findUnique({ slug: S.decodeSync(NonEmptyString100)('getting-started'), }) invariant(helpArticle, 'Help article not found') return { writing: helpArticle } }
The way I'm fetching data from it is not important here since there's a lot going on underneath it. The point here is I have a neat data loading pattern that doesn't care where the data lives but can provide it the components. The same goes for clientAction
too. Another cool thing about clientLoader
and clientAction
is, they can be used together with loader
and action
. I don't use it for Aurelius, but it's there if I need it. I'm not aware of any framework that is flexible enough to have data loading and mutation patterns like Remix does.
There are a couple of things that aren't great though. Right now, Aurelius still uses SSR. Which seems weird since there's nothing to do in the server. There's no way to hydrate the app on the server side because there's no data there. I'm aware of Remix's SPA mode so I'll switch to it soon.
The other thing is, even though I can use useLoaderData
to get the data from a clientLoader
, I can't defer
it and use Await
in the component. So while the clientLoader
is reading the data, however tiny the time taken for the query, I can't show a Suspense
fallback. This is noticeable when the app is loaded the first time because it runs a few migrations to set everything up.