Understanding State Management in Remix Applications

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, or LoginForm
  • 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 action or loader is stored here. 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).

https://remix.run/docs/en/main/hooks/use-fetcher#fetcherdata

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, useFetcher generate a unique fetcher scoped to that component (however, it may be looked up in useFetchers() 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 the key option:

https://remix.run/docs/en/main/hooks/use-fetcher#key

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.