Tom MacWright

tom@macwright.com

Remix fetcher and action gripes

We use Remix at Val Town and I’ve been a pretty big fan of it: it has felt like a very responsible project that is mostly out of the way for us. That said, the way it works with forms is still a real annoyance. I’ve griped about this in an indirect, vague manner, and this document is an attempt to make those gripes more actionable and concrete.

Gripes with FormData

Remix embraces traditional forms, which means that it also embraces the FormData object in JavaScript and the application/x-www-form-urlencoded encoding, which is the default for <form> elements which use POST. This is great for backwards-compatibility but, like we’ve known about for years, it just sucks, for well-established reasons:

  • It is an encoding for text data only. If you have a number input, its value is sent to the server as a string.
  • Checkbox and radio inputs are only included with the form data if they’re checked. There is no undefined in form encoding.

These limitations trickle into good libraries that try to help, like conform, which says:

Conform already preprocesses empty values to undefined. Add .default() to your schema to define a default value that will be returned instead.

So let’s say you have a form in your application for writing a README. Someone writes their readme, tries to edit it and deletes everything from the textarea, hits submit, and now the value of the input, on the server side, is undefined instead of a string. Not great! You can see why conform would do this, because of how limited FormData is, but still, it requires us to write workarounds and Zod schemas that are anticipating Conform, FormData, and our form elements all working in this awkward way.

Gripes with form submissions and useEffect

This is kind of gripes with “reacting to form submission in Remix” but it often distills down to useEffect. When you submit a form or a fetcher in Remix, how do you do something after the form is submitted, like closing a modal, showing a toast, resetting some input, or so on?

Now, one answer is that you should ask “how would you do this without JavaScript,” and sometimes there’s a pleasant answer to that thought experiment. But often there isn’t such a nice answer out there, and you really do kind of just want Remix to just tell you that the form has been submitted.

So, the semi-canonical way to react to a form being submitted is something like this code fragment, cribbed from nico.fyi:

function Example() {
	let fetcher = useFetcher<typeof action>();
	
	useEffect(() => {
		if (fetcher.state === 'idle' && fetcher.data?.ok) {
			// The form has been submitted and has returned
			// successfully
		}
	}, [fetcher]);
	
	return <fetcher.Form method="post">
	   // …

The truth is, I will do absolutely anything to avoid using useEffect. Keep it away from me. React now documents its usage as saying to “synchronize a component with an external system”, which is almost explicitly a hint to try not to use it ever. Is closing a modal a form of synchronizing with another system, probably not?

The problems with useEffect are many:

  • A lot of times you’ll have something that you’re reacting to, like in the example above, fetcher: when fetcher changes, you want the effect to run. It is a trigger. But you also have things that you want to use in the function, like other functions or bits of state - thing that you aren’t reacting to, but just using. But you have to have both categories of things in the useEffect dependency array, or your eslint rule will yell at you.
  • The effect runs whenever the things in its dependency array changes, but knowing when they’ll change is like a whole extra layer of programming-cognitive-overload that is not reflected in type systems and is hard to debug and document. Does the identity of a setState function stay the same? How about one that you get from a third-party library like Jotai? Does the useUtils hook of tRPC guarantee stable identities? Do the parts of a fetcher change? The common answer is “maybe,” and it’s constantly under-documented.
  • Effects require you to think about some state machine of fetcher.state and fetcher.data?.ok, or whatever you’re depending on, and figure out where exactly is the state that you’re looking for, and hope that there aren’t unusual ways to enter that state. Like let’s say that you’re showing a toast whenever fetcher.state === 'idle' && fetcher.data?.ok. Is there a way to show a toast twice? The docs for fetcher.data say “Once the data is set, it persists on the fetcher even through reloads and resubmissions (like calling fetcher.load() again after having already read the data),” so is it possible for fetcher.state to go from idle to loading to idle again and show another toast despite not performing the action again? It sure seems so!

The “ergonomics” of this API are rough: it’s too easy to write a useEffect hook that fires too few or too many times.

I have a lot of JavaScript experience and I understand object mutability and most of the equality semantics, but when I’m thinking about stable object references in a big system, with many of my variables coming from third-party libraries that don’t explicitly document their stability promises, it’s rough.