To be clear, this post is about me learning to understand state management in Remix applications. It’s not about me telling you how how Remix manages state.
Reference: https://remix.run/docs/en/main/discussion/state-management.
Form validation
I’ll use form validation as a way of looking into how Remix manages state. Here’s a slightly modified version of the form from the Form Validation Guide (with Tailwind styles to keep things looking nice) :
// app/routes/form-validation-example.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
const validateEmail = (email: string) => {
return /\S+@\S+\.\S+/.test(email);
};
const validateUsername = (username: string) => {
return username.length >= 3;
};
const validatePassword = (password: string) => {
return password.length >= 8;
};
type FormErrors = {
email?: string;
username?: string;
password?: string;
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = String(formData.get("email"));
const username = String(formData.get("username"));
const password = String(formData.get("password"));
const errors: FormErrors = {};
if (!validateEmail(email)) {
errors.email = "Invalid email address";
}
if (!validateUsername(username)) {
errors.username = "Invalid username";
}
if (!validatePassword(password)) {
errors.password = "Invalid password";
}
if (Object.keys(errors).length > 0) {
return json({ errors });
}
return redirect("/");
};
export default function formValidationExample() {
const actionData = useActionData<typeof action>();
let submitDisabled = false;
return (
<div className="mx-auto w-60 my-12">
<Form method="post">
<p className="mb-3">
<label htmlFor="email">Email</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="email"
name="email"
/>
{actionData?.errors?.email && <em>{actionData.errors.email}</em>}
</p>
<p className="mb-3">
<label htmlFor="username">Username</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="text"
name="username"
/>
{actionData?.errors?.username && (
<em>{actionData.errors.username}</em>
)}
</p>
<p className="mb-3">
<label htmlFor="password">Password</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="password"
name="password"
/>
{actionData?.errors?.password && (
<em>{actionData.errors.password}</em>
)}
</p>
<button
disabled={submitDisabled}
className={`px-2 py-1 rounded-sm text-cyan-900 ${
submitDisabled ? "bg-grey-400" : "bg-cyan-50"
}`}
type="submit"
>
Sign Up
</button>
</Form>
</div>
);
}
I’ve added a submitDisabled
variable. It set to false
for now.
The code works as expected:
The form’s values are preserved after the form is submitted without having to pass the previously submitted values back from the server! That’s kind of a big deal. I’d assumed I needed to pass the values back to the client from the server.
What’s going on is that Remix intercepts the form submission event and makes the request to the server in the background. (Possibly using the Fetch API?) From my browser’s network tab, I’m seeing POST
requests made to http://localhost:5173/form-validation-example?_data=routes%2Fform-validation-example
when the form is submitted.
The request triggers the route’s action
. The action
passes the data back to the component, causing the component to re-render. I want to know more about how React preserves state when a component is re-rendered. It has something to do with React’s Virtual DOM – an in-memory data structure cache that represents the web page’s elements (components?).
The Virtual DOM is a lightweight representation of the actual DOM:
The Document Object Model (DOM) is a programming API for HTML and XML documents. It defines the logical structure of documents and the way a document is accessed and manipulated. In the DOM specification, the term “document” is used in the broad sense – increasingly, XML is being used as a way of representing many different kinds of information that may be stored in diverse systems, and much of this would traditionally be seen as data rather than as documents. Nevertheless, XML presents this data as documents, and the DOM may be used to manage this data.
https://www.w3.org/TR/WD-DOM/introduction.html
What’s going on with React is something like:
- when a React component is rendered, React creates a Virtual DOM representation of the component’s return JSX (it’s default export?)
- when a component’s state or props change, React creates a new Virtual DOM tree to represent the changes
- React then compares the new Virtual DOM tree with a (snapshot of?) the previous Virtual DOM tree to identify what has changed between the two versions (the process is called “diffing”)
- based on the difference between current and previous Virtual DOM trees, React updates the parts of the actual DOM tree that need to be changed. This minimizes direct DOM manipulation. (I’m assuming this is why direct DOM manipulation is discouraged in the React world.)
- React also batches multiple DOM updates together to improve performance.
What is the Virtual DOM?
It’s a tree of Javascript objects. The JSX (Javascript XML) that’s used to write React components is translated into Javascript objects. JSX is transpiled into React.createElement
calls. React.createElement
generates Javascript objects. For example:
// JSX
const element = <div className="greeting">Hello, world!</div>;
Gets transpiled to something like this:
// Javascript
const element = React.createElement(
'div',
{ className: 'greeting' },
'Hello, world!'
);
React.createElement
takes at least two arguments:
- the type of element, for example
div
, orLoginForm
- props: an object containing the element’s properties (and children?)
It can also take any number of children as subsequent arguments.
The Javascript that the JSX code is transpiled into is a Javascript object that describes an (actual) DOM node. This is what React uses internally to represent the VDOM.
The structure of the VDOM (closely) mirrors the structure of the actual DOM, but it exists entirely in memory (on the client).
What’s the actual DOM?
It’s an interface (or it provides an interface?) for manipulating HTML and XML documents. Programming languages can access and modify documents via its API. In web browsers, this is primarily (entirely?) done with Javascript. This makes more sense when I think of it in terms of how the Ruby Nokogiri library can be used to parse HTML and XML documents.
Maybe what’s throwing me off is that I’m not paying attention to what DOM stands for (document object model.) With that in mind, the DOM provides a way of representing (modeling?) HTML documents. It also provides an API for interacting with HTML documents. I’ll leave it at that for now.
Why not just manipulate the actual DOM?
It does seem easier sometimes, but (in the context of a Remix/React application):
- it can cause a Virtual DOM mismatch: as noted above, React maintains an internal representation (on the client) of the DOM (the VDOM.) It expects this representation to be in sync with the actual DOM. Manipulating the DOM directly (for example:
document.getElementById('foo').textContent = 'bar';
) can cause the VDOM to get out of sync with the actual DOM. This can lead to unexpected behaviour. - direct DOM manipulation bypasses the optimizations that are provided by React’s handling of the VDOM
- issues with event handling: React has its own event handling system that’s an abstraction of native DOM events. Adding event listeners outside of React’s system can cause events to not be properly cleaned up. (Lead to memory leaks?)
Now back to understanding state in the context of validating Remix form inputs.
Validate fields prior to form submission
It’s common for applications to validate form fields before the form has been submitted to the server. For example, indicating that an email or username is not available. This seems like a good enough example to use for getting a sense of how state can be managed in Remix.
I’ve modified the form from the beginning of this post to add emailAvailable
and usernameAvailable
error fields. The getUserByEmail
and getUserByUsername
functions are querying a users
array that’s hard coded into app/models/users.ts
:
form-validation-example.tsx
// app/routes/form-validation-example.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { getUserByEmail, getUserByUsername } from "~/models/users";
const validateEmail = (email: string) => {
return /\S+@\S+\.\S+/.test(email);
};
const emailAvailable = (email: string) => {
return !getUserByEmail(email);
};
const usernameAvailable = (username: string) => {
return !getUserByUsername(username);
};
const validateUsername = (username: string) => {
return username.length >= 3;
};
const validatePassword = (password: string) => {
return password.length >= 8;
};
type FormErrors = {
emailValid?: string;
emailAvailable?: string;
usernameValid?: string;
usernameAvailable?: string;
passwordValid?: string;
};
export const action = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = String(formData.get("email"));
const username = String(formData.get("username"));
const password = String(formData.get("password"));
const errors: FormErrors = {};
if (!validateEmail(email)) {
errors.emailValid = "Invalid email address";
}
if (!emailAvailable(email)) {
errors.emailAvailable = "Email not available";
}
if (!validateUsername(username)) {
errors.usernameValid = "Invalid username";
}
if (!usernameAvailable(username)) {
errors.usernameAvailable = "Username not available";
}
if (!validatePassword(password)) {
errors.passwordValid = "Invalid password";
}
if (Object.keys(errors).length > 0) {
return json({ errors });
}
return redirect("/");
};
export default function formValidationExample() {
const actionData = useActionData<typeof action>();
let submitDisabled = false;
return (
<div className="mx-auto w-60 my-12">
<Form method="post">
<p className="mb-3">
<label htmlFor="email">Email</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="email"
name="email"
/>
{actionData?.errors?.emailValid && (
<em>{actionData.errors.emailValid}</em>
)}
{actionData?.errors?.emailAvailable && (
<em>{actionData.errors.emailAvailable}</em>
)}
</p>
<p className="mb-3">
<label htmlFor="username">Username</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="text"
name="username"
/>
{actionData?.errors?.usernameValid && (
<em>{actionData.errors.usernameValid}</em>
)}
{actionData?.errors?.usernameAvailable && (
<em>{actionData.errors.usernameAvailable}</em>
)}
</p>
<p className="mb-3">
<label htmlFor="password">Password</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="password"
name="password"
/>
{actionData?.errors?.passwordValid && (
<em>{actionData.errors.passwordValid}</em>
)}
</p>
<button
className={`px-2 py-1 rounded-sm text-cyan-900 ${
submitDisabled ? "bg-grey-400" : "bg-cyan-50"
}`}
type="submit"
>
Sign Up
</button>
</Form>
</div>
);
}
This works:
but the fields aren’t validated until the form is submitted. I’d like the application to check if the email and username are available prior to the form’s submission.
Starting with just the email
field, it can be accessed with an onInput
event handler. The handler function has access to the email input’s value.
The application’s going to make a request to the server to check if the email address is available. It can’t be allowed to make a request on every keystroke, so the calls to the server need to be wrapped in a “debounce” function.
Based on trial end error, it seems that the event handler function itself can’t be wrapped in a debounce function. When I try that event.currentTarget
returns null
. Possibly this is related to the debounce function that I’m using.
// app/routes/form-validation-example.tsx
//...
export default function formValidationExample() {
const actionData = useActionData<typeof action>();
let submitDisabled = false;
const debouncedEmailAvailablityCheck = debounce((email: string) => {
console.log(email);
// make an API request to check if the email is available
}, 500);
// I'm not sure why, but if this event handler is wrapped in a `debounce` function,
// `event.currentTarget` will be `null`. It's _not_ due to event pooling, as that feature has
// been removed from React. In any case, the approach used here works.
const checkEmailAvailability = (event: React.FormEvent<HTMLInputElement>) => {
const email = event.currentTarget.value;
// don't do anything until the email is possibly valid:
if (validateEmail(email)) {
debouncedEmailAvailablityCheck(email);
}
};
return (
<div className="mx-auto w-60 my-12">
<Form method="post">
<p className="mb-3">
<label htmlFor="email">Email</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="email"
name="email"
onInput={checkEmailAvailability}
/>
{actionData?.errors?.emailValid && (
<em>{actionData.errors.emailValid}</em>
)}
{actionData?.errors?.emailAvailable && (
<em>{actionData.errors.emailAvailable}</em>
)}
</p>
//...
For reference, here’s the debounce
function:
debounce.ts
// app/utilities/debounce.ts
export default function debounce<F extends (...args: any[]) => void>(
func: F,
wait: number
) {
let timeout: ReturnType<typeof setTimeout> | null = null;
return function (this: any, ...args: Parameters<F>) {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(this, args);
}, wait);
};
}
The debouncedEmailAvailabilityCheck
function can make an API call to a resource route:
// app/routes/api.emailExists.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getUserByEmail } from "~/models/users";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const email = url.searchParams.get("email");
let emailExists = false;
if (email) {
emailExists = (await getUserByEmail(email)) && true;
}
return json({ emailExists: emailExists });
};
It seems it would be technically possible to call the resource route with:
fetch(`/api/emailExists?email=${email}`)
but I want to make use of how useFetcher
preserves state: https://remix.run/docs/en/main/hooks/use-fetcher#usefetcher.
This works:
// app/routes/form-validation-example.tsx
//...
export default function formValidationExample() {
const actionData = useActionData<typeof action>();
const fetcher = useFetcher<FormValidationFetcher>();
let emailAvailable = true;
if (fetcher && fetcher?.data) {
emailAvailable = fetcher.data?.emailExists ? false : true;
console.log(
`setting emailAvailable variable from fetcher: ${emailAvailable}`
);
}
let submitDisabled = false;
const debouncedEmailAvailablityCheck = debounce((email: string) => {
fetcher.load(`/api/emailExists?email=${email}`);
}, 500);
const checkEmailAvailability = (event: React.FormEvent<HTMLInputElement>) => {
const email = event.currentTarget.value;
if (validateEmail(email)) {
debouncedEmailAvailablityCheck(email);
}
};
//...
The returned response data from your
https://remix.run/docs/en/main/hooks/use-fetcher#fetcherdataaction
orloader
is stored here. Once the data is set, it persists on the fetcher even through reloads and resubmissions (like callingfetcher.load()
again after having already read the data).
As the docs note, the data persists on the fetcher when the form is resubmitted.
But what happens to the state?
When fetcher.load
is called with a new API route, the previous fetcher data doesn’t persist.
Test:
export default function formValidationExample() {
const actionData = useActionData<typeof action>();
const fetcher = useFetcher<FormValidationFetcher>();
let emailAvailable = true,
usernameAvailable = true;
if (fetcher && fetcher?.data) {
emailAvailable = fetcher.data?.emailExists ? false : true;
usernameAvailable = fetcher.data?.usernameExists ? false : true;
}
let submitDisabled = false;
const debouncedEmailAvailablityCheck = debounce((email: string) => {
fetcher.load(`/api/emailExists?email=${email}`);
}, 500);
const checkEmailAvailability = (event: React.FormEvent<HTMLInputElement>) => {
const email = event.currentTarget.value;
if (validateEmail(email)) {
debouncedEmailAvailablityCheck(email);
}
};
const debouncedUsernameAvailablityCheck = debounce((username: string) => {
fetcher.load(`/api/usernameExists?username=${username}`);
}, 500);
const checkUsernameAvailability = (
event: React.FormEvent<HTMLInputElement>
) => {
const username = event.currentTarget.value;
debouncedUsernameAvailablityCheck(username);
};
In the screenshot below, neither the email or username are available, but if I ignore the “Email taken” message and enter a username that’s also been taken, the “Email taken” message disappears. That makes sense:
// fetcher.data after entering "bob@example.com"
// into the Email input:
original fetcher.data
{
"emailExists": true
}
// fetcher.data after entering "sally" into
// the Username input
{
"usernameExists": true
}
If I’m understanding things correctly, the fetcher data persists when the form is re-submitted, but it doesn’t persist when a new call is made to fetcher.load(<some_route>)
. I guess I had the expectation that the data would be merged, so that after the second call, fetcher.data
would be something like:
[
{
"emailExists": true
},
{
"usernameExists": true
},
]
I’m not sure if there’s a way of achieving that without persisting the results of fetcher.data
with useState
.
In any case, I think there’s a good workaround:
By default,
https://remix.run/docs/en/main/hooks/use-fetcher#keyuseFetcher
generate a unique fetcher scoped to that component (however, it may be looked up inuseFetchers()
while in-flight). If you want to identify a fetcher with your own key such that you can access it from elsewhere in your app, you can do that with thekey
option:
Is this a hack? It seems valid to me and simplifies the code. The call to /api/emailExists
is loaded into the "user-by-email"
fetcher and the call to /api/usernameExists
is loaded into the "user-by-username"
fetcher. That prevents the data from being overwritten by fetcher.load
calls to a different resource route:
// app/routes/form-validation-example.tsx
//...
export default function formValidationExample() {
const actionData = useActionData<typeof action>();
const emailFetcher = useFetcher<FormValidationFetcher>({
key: "user-by-email",
});
const usernameFetcher = useFetcher<FormValidationFetcher>({
key: "user-by-username",
});
let emailAvailable = true,
usernameAvailable = true;
if (emailFetcher && emailFetcher?.data) {
emailAvailable = emailFetcher?.data?.emailExists ? false : true;
}
if (usernameFetcher && usernameFetcher?.data) {
usernameAvailable = usernameFetcher.data?.usernameExists ? false : true;
}
const debouncedEmailAvailablityCheck = debounce((email: string) => {
emailFetcher.load(`/api/emailExists?email=${email}`);
}, 500);
const handleEmailInput = (event: React.FormEvent<HTMLInputElement>) => {
const email = event.currentTarget.value;
if (validateEmail(email)) {
debouncedEmailAvailablityCheck(email);
}
};
const debouncedUsernameAvailablityCheck = debounce((username: string) => {
usernameFetcher.load(`/api/usernameExists?username=${username}`);
}, 500);
const handleUsernameInput = (event: React.FormEvent<HTMLInputElement>) => {
const username = event.currentTarget.value;
if (username.length >= 3) {
debouncedUsernameAvailablityCheck(username);
}
};
return (
<div className="mx-auto w-60 my-12">
<Form method="post">
<p className="mb-3">
<label htmlFor="email">Email</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="email"
name="email"
required={true}
maxLength={255}
onInput={handleEmailInput}
/>
{actionData?.errors?.emailValid && (
<em>{actionData.errors.emailValid}</em>
)}
{!emailAvailable && <em>Email address taken</em>}
</p>
<p className="mb-3">
<label htmlFor="username">Username</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="text"
name="username"
required={true}
minLength={3}
onInput={handleUsernameInput}
/>
{actionData?.errors?.usernameValid && (
<em>{actionData.errors.usernameValid}</em>
)}
{!usernameAvailable && <em>Username taken</em>}
</p>
<p className="mb-3">
<label htmlFor="password">Password</label>
<input
className="px-1 border border-slate-500 rounded-sm text-cyan-900"
type="password"
name="password"
required={true}
minLength={8}
/>
{actionData?.errors?.passwordValid && (
<em>{actionData.errors.passwordValid}</em>
)}
</p>
<button
className={`px-2 py-1 rounded-sm text-slate-900 bg-slate-50`}
type="submit"
>
Sign Up
</button>
</Form>
</div>
);
}
Both error messages can be displayed before the form is submitted!
The only issue I’m seeing is that the form can be submitted with an invalid email address and username.
The easiest solution to that might be:
//...
let emailAvailable: boolean | undefined,
usernameAvailable: boolean | undefined,
submitDisabled = true;
if (emailFetcher && emailFetcher?.data) {
emailAvailable = emailFetcher?.data?.emailExists ? false : true;
}
if (usernameFetcher && usernameFetcher?.data) {
usernameAvailable = usernameFetcher.data?.usernameExists ? false : true;
}
//...
if (
emailFetcher.data &&
!emailFetcher.data.emailExists &&
usernameFetcher.data &&
!usernameFetcher.data.usernameExists
) {
submitDisabled = false;
}
//...
<button
className={`px-2 py-1 rounded-sm text-slate-900 ${
submitDisabled ? "bg-slate-400" : "bg-slate-50"
}`}
type="submit"
>
Sign Up
</button>
That’s neat! It doesn’t quite meet the requirement, but it’s interesting that it works to update the local submitDisabled
variable. I’m guessing that’s because the local variable is dependent on fetcher data?
But, it’s not quite right:
In the above screenshot, the username is taken and the “Sign Up” button has the disabled styles. When I edit the username:
The code is doing what it says it does. Both the email and username are valid, so the “Sign Up” button doesn’t have the disabled styles. The form can’t actually be submitted though, as “password” is a required field:
I think there’s a CSS solution to this, but I’m trying to learn about state.
This is fairly straightforward:
const [passwordValid, setPasswordValid] = useState<boolean | undefined>();
let emailAvailable: boolean | undefined,
usernameAvailable: boolean | undefined,
submitDisabled = true;
//...
const handlePasswordInput = (event: React.FormEvent<HTMLInputElement>) => {
const password = event.currentTarget.value;
let valid: boolean;
valid = password.length >= 8;
setPasswordValid(valid);
};
if (
emailFetcher.data &&
!emailFetcher.data.emailExists &&
usernameFetcher.data &&
!usernameFetcher.data.usernameExists &&
passwordValid
) {
submitDisabled = false;
}
//...
<button
className={`px-2 py-1 rounded-sm text-slate-900 ${
submitDisabled
? "bg-slate-400 cursor-default"
: "bg-slate-50 cursor-pointer"
}`}
type="submit"
disabled={submitDisabled}
>
Sign Up
</button>
</Form>
</div>
);
}
That’s good, but there’s a small problem.
An invalid password is entered. So far so good:
Now the invalid password is deleted:
Showing the password warning after the field has been deleted feels a little obnoxious. The warning could be removed by adding another state variable, but I’ll ignore it for now. The message is technically correct.
I have some non-tech friends who get frustrated by online forms. I’m trying to do my bit to improve things.
For reference, the code is here: https://github.com/scossar/fetcher_key_test.
One last thing to look at:
Fetcher state
It’s not neede for this form, but fetcher.state
can be used to get information about whether the fetcher is “idle”, “submitting”, or “loading”:
let loading: boolean | undefined;
if (emailFetcher && emailFetcher?.data) {
loading = emailFetcher.state === "loading" ? true : false;
The loading
variable could then be used to add feedback to the UI.
Remix State Management
I’m not sure I can do better than link to https://remix.run/docs/en/main/discussion/state-management.
This post was partly inspired by ChatGPT’s insistence on recommending approaches to state management that the Remix docs suggest are often an anti-pattern.