Aug 8, 2024#local-first·#remix

Remix Is Great for Local-First Apps

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.

Share this on:X