These past months I've been working with Remix. The release of Next.js 13 and its follow-ups didn't make much impression on me, and if anything they were kind of a deal breaker. Consequently, I decided to take a closer look at Remix, and I was blown away by its ease of use.
In short, I find the Remix approach simpler. I write my backend, and I use the loader
& action
functions to communicate with React. I'm oversimplifying, but that's all I perceive. In reality, it's a well thought, and complex framework that does such a good job hiding all the complexity from me. And it just works, everywhere.
Anyway, let's go through some conventions I decided on.
Table of contents
- Throw in loaders, return in actions
- Parse formData
- Improved response types
- Parse ENV variables on load
- Feature organization & business logic
Throw in loaders, return in actions
By reading the documentation you might have noticed some snippets like this one:
Now a question to be asked is what's the difference between throwing and returning a response?
When it comes to redirects (30X status codes), there's no visible difference. It's down to semantics. Let's see two examples:
- Password reset success - Let's take the user to the login page
return redirect('/login')
- No session found - Nope. No access for you
throw redirect('/login')
By throwing, you let the next developer understand that the redirect is forced due to some bad request.
When it comes to every other response (non-30X status) though, it's all about how you handle the errors. When throwing a response, we diverge from the happy path, and the error is handled in the closest ErrorBoundary. This isn't very helpful at times, as you don't want to show an ErrorBoundary
if a username is taken.
I usually like to do the following:
- Throw in loaders. The page can't be rendered properly. Many things can happen here. (Missing permissions, database errors, etc.) The page is broken, so rendering the
ErrorBoundary
makes sense. - Return in actions. Handle the errors as values, and depending on the severity render the appropriate UI. Validation error? Inline the error message. Database hiccup? Could be a toast.
Of course, this isn't set in stone, but a good rule of thumb for me.
Parse formData
What fresh air to just use uncontrolled components, and gather everything in a FormData instance, right? Unfortunately, everything you pick from it is typed as FormDataEntryValue | null
.
There are all sorts of validation libraries you can use, but I like to keep it simple. I gather the formData, parse them on the server with zod, and return validation errors instead.
Something like this:
Unrelated to Remix, I'm experimenting with effect-ts, so here's what I really do:
I'm not an Effect-ts expert, happy to be corrected and listen to suggestions. Here's my Twitter/X
If this scares you ignore it. What I want to highlight is how flexible Remix is. If I want to overengineer something, I can do so.
Improved response types
I find the infered types that useLoaderData
, useActionData
& useFetcher
return to be slightly incorrect (Issue-3931).
For that case I use remix-typedjson and its hooks. So instead of useFetcher
I use useTypedFetcher
and so on.
I'm on the fence about this, as I'm using a "patched" version of Remix's helpers. There might be dragons here, so feel free to ignore this advice.
Parse ENV variables on load
This isn't something groundbreaking, or strictly related to Remix, but I never notice it when reading similar introductory articles.
Essentially, don't do this:
Instead, parse process.ENV
, and let the application crash if something is missing. You don't want to have your app running, only to find out that your emails were never sent.
Here's what to do instead:
The same goes for all other similar instances. Initializing database/SMTP connections, etc
You can colocate this in a single env.server.ts
file, but whatever you do, parse your env variables.
Feature organization & business logic
Here's how I organize my projects (so far)
I don't like these oversimplified examples where you do everything in your actions. Take that business logic, and create a dedicated file for it. I like to group them under modules/use-cases
. There are many approaches, you can go wild with DDD, whatever - just don't blindly add everything to the loaders.
As for the front end, I try to keep my component library in a different (PNPM) workspace. One-offs can live inside the route, while exceptions (e.g. <TeamSwitcher />
, <UserNav />
) can go in a /components
folder.
Oh, almost forgot. I like to colocate everything I need inside the same route. A single folder _route.tsx
re-exports everything (meta, default-page, loader, action). I'm writing this piece before Remix V2 lands, so I'm using the Remix Flat Routes package to make it happen.
I'm not dogmatic. I'll happily throw everything out of the window if it's slowing me down. For now, this setup works, but will change in the future.
Outro
I want a couple of things from my SSR framework:
- Let me run my business logic on the server. Don't be too smart about it.
- Make the transition to React seamless.
So far, Remix covers all. There are sharp edges, but nothing blocking.
Is it a batteries-included framework like Laravel, Rails, or (the closest thing in the Node.js land) Adonis.js?
Nope, and I don't mind. If anything the JS ecosystem has so many tools and options, that I would dislike having certain libraries shoehorned.
Remix gets out of my way and lets me just write code. It has the right amount of conventions, without being restricting or weird.
Resources
- Remix docs
- Remix Guide (content aggregator)
- How to Think About Remix (afloat.dev)
- remix-flat-routes (GitHub)
- remix-typed-json (GitHub)
- Throwing vs. Returning responses in Remix (sergiodxa.com)
- Colocate your routes into feature folders with Remix Custom Routes (jacobparis.com)
- Typesafe environment variables with Zod (jacobparis.com)
Frankly, follow both Jacob Paris & Sergio Xalambrà in your RSS feeds. They have great content.