First thoughts on Remix
I gave Remix a quick spin following their “Jokes App” tutorial and gathered some notes. These are just my initial thoughts during my first contact with the framework. For a proper introduction, read the documentation.
Remix is a seamless server and browser runtime that provides snappy page loads and instant transitions by leveraging distributed systems and native browser features instead of clunky static builds. Built on the Web Fetch API (instead of Node) it can run anywhere. It already runs natively on Cloudflare Workers, and of course supports serverless and traditional Node.js environments, so you can come as you are.
The website is the result of a great amount of marketing effort. It’s amazing what JS frameworks do these days to get noticed. Regardless of whether the framework lives up to the hype or not, I get a strange feeling from the website: the language attempts to be humorous and light, and this doesn’t help me build trust.
From my notes, the most interesting parts are probably on the <ScrollRestoration />
component and on the loader pattern.
On setting up Remix #
- Remix nuked my
README.mdfile (on which I already had some notes) and I had to recreate it. Should have expected that! - Since Remix picks up on certain exports from files (like
export const meta) but my IDE doesn’t know about that, I’m getting a bunch of “unused export” linter errors on those lines. I’m not sure where the errors are coming from, since from my recollectioneslintdoesn’t complain about unused exports. It has no knowledge of the other files. It’s probably IntelliJ, then. - The default
root.tsxcontains several interesting elements:<Outlet />which is like a Svelte<slot />: it renders children components.<ScrollRestoration />, probably aptly-named. Its presence surprises me because this used to be a feature ofreact-routerthat got dropped after the developers noticed scroll restoration working increasingly better out of the box on major browsers.TODO: find out how<ScrollRestoration />is implemented.- Remix documentation on scroll restoration mentions that this component works
by restoring the scroll level before rehydration. This should eliminate the jarring effect of
having the scroll point being restored after the page loads completely.
- The “before rehydration” part is true only by virtue of the
<ScrollRestoration />component being used one line before the<Scripts />component.
- The “before rehydration” part is true only by virtue of the
- The
yarn buildcommand finished in 0.44 seconds. Nice! - I like that the default app doesn’t contain images or marketing text selling Remix. It just has some documentation links.
The <ScrollRestoration /> component #
It’s implemented here and works roughly so:
- It disables the browser implementation with
window.history.scrollRestoration = "manual", but only in auseEffectcall that runs afteruseLayoutEffect(used for other effects in the component). - Provides a
<script>tag that runs a simpler effect (it’s simpler because it doesn’t need to know about browser history, since it runs only on the initial script load before hydration). - Only after the component has hydrated does the component run some more complex logic (e.g. to
respect
location.hash) that ultimately may runscrollTo(0, position). - Scroll positions are stored in an in-memory
positionsdictionary where the keys arelocation.keys. - To survive for longer,
positionsgets added tolocalStorageand is used to restorepositionsinto memory when the script runs again.
On first-contact DX #
- Live reload works great and spinning up the development server takes no time.
- While adding some code and missing an import, the app showed me a build error (expectedly). But then after fixing the error, the app didn’t go back to a normal state, and the error remained, even though the build didn’t show the error any longer.
- Adding the new routes, I’m thinking that the structure recommended by the tutorial
where
jokes/newshares a namespace withjokes/$jokeIdis not great. What if I wanted slugs instead of IDs, and someone created a joke titlednew? Other solutions aren’t as pretty, though:jokes/show/$jokeId + jokes/new, ornew-joke + jokes/$jokeId. - By exporting a
linksarray, you can add<link>elements to<head>sort of like withreact-helmet. They then get picked up by a top-level<Links />component. - The tutorial recommends a file structure for styles that’s not great: a
stylesdirectory separate from other parts of the app, such asroutes. If the CSS I write is encapsulated together with a route, then why shouldn’t their files live together? Having a separatestylesdirectory adds quite a bit of indirection and could make a project hard to navigate.
On TypeScript use #
- The default
entry.server.tsxfile contains ahandleRequestfunction that takes arequest. That’s fine, but it also takes aresponseStatusCodeandresponseHeaders. Am I still able to decide what status code to respond with? This signature is a bit weird to me.- Looks like there’s a default
200response and I still have a chance to change it, as expected. Still, the signature feels awkward.
- Looks like there’s a default
- I wonder why the tutorial recommends using
export let. It looks to me as if the things I’m exporting shouldn’t ever be reassigned. I’m changing these toexport const, hoping that nothing explodes.- It looks like in other areas of their documentation they use
export const, which makes more sense.
- It looks like in other areas of their documentation they use
- The
loaderpattern (see related docs) with anexport const loadercoupled with auseLoaderDatacall imported fromremixfeels a bit weird to me. I suppose there must be quite a bit going on at hydration time for this to be worth the indirection. My first impression is that it shouldn’t be necessary? Why can’t I goexport const loader = someRemixUtility(async () => {})instead, removing theuseLoaderDatacall inside the component? The client and server bundles can still provide different implementations ofsomeRemixUtility.TODO: find out whyexport const loadercan’t be used directly and needs to be accessed viauseLoaderData. My guess: the purpose is to call the loader during server rendering and then to reuse the same data during rehydration, to initialize a frontend cache with. Remix developers simply decided to go for a standard way to access Remix functionality, and this just looks consistent.- It looks like I was correct: Remix hands off serialized data to the client as a string and then has the client route reuse this data. It works similarly to what Apollo GraphQL recommends.
- There’s another insidious result of the indirection introduced by the
useLoaderDataandexport const loaderpattern, aggravated by the fact that the suggested typeLoaderFunctionisn’t generic: discrepancies between whatloaderactually returns and whatuseLoaderDatareturns aren’t going to be caught unless a type is shared between them. ButLoaderFunctionisn’t generic, so there’s no enforcement from Remix to make sure that this is the case. I can have aloaderthat returnsnumberbut then accessstring[]inuseLoaderDataand unless I actively share the types, TypeScript won’t have a chance to complain. In my opinion,LoaderFunctionshould take a required type argument for the return type, and another for query parameters, which can probably be optional.- One could argue that this is the responsibility of the developer, and it is, but by not requiring any type arguments, Remix isn’t helping.
- It’s also inconsistent because
LoaderFunctiontakes no type arguments butuseLoaderDatatakes a type argument for the returned data.
- When submitting data, the type mismatch is more accentuated, because
POSTrequests containFormData, which bears (as far as I know) no information on the shape of the data from the form. In this case, however, it matters less, because the tutorial calls the user to perform backend validation/parsing of this form data, by goingform.get("field-name")and then validating the result.- I wonder whether building a way to type-check JSX forms and providing e.g. a TS
eslintplug-in that uses the full power of the AST to build type-safe forms would be worth it. I suppose exporting a simple component for use,TypedForm<T>would be enough, or perhaps even more magic, somehow simply type-check<form>elements based on their current children. This is probably not possible because one would need to go into other modules. Maybe theTypedForm<T>approach is good as long as one sharesTwith child controls.
- I wonder whether building a way to type-check JSX forms and providing e.g. a TS
That’s it for now! I’ll probably give Remix a proper try once I have a need for it. I feel like the typing issues I mentioned can be avoided through discipline, and the DX looks promising.
Relevant documentation:
- Remix documentation
- The
loaderpattern in Remix - React Router documentation on scroll restoration
useLayoutEffectdocumentation