React Router v7 has been released. View the docs
Data Writes
On this page

Data Writes

Data writes (some people call these mutations) in Remix are built on top of two fundamental web APIs: <form> and HTTP. We then use progressive enhancement to enable optimistic UI, loading indicators, and validation feedback--but the programming model is still built on HTML forms.

When the user submits a form, Remix will:

  1. Call the action for the form
  2. Reload all the data for all the routes on the page

Many times people reach for global state management libraries in React like redux, data libs like apollo, and fetch wrappers like React Query in order to help manage getting server state into your components and keeping the UI in sync with it when the user changes it. Remix's HTML based API replaces the majority of use cases for these tools. Remix knows how to load the data as well as how to revalidate it after it changes when you use standard HTML APIs.

There are a few ways to call an action and get the routes to revalidate:

This guide only covers <Form>. We suggest you read the docs for the other two after this guide to get a sense of how to use them. Most of this guide applies to useSubmit but useFetcher is a bit different.

Plain HTML Forms

After teaching workshops with our company React Training for years, we've learned that a lot of newer web developers (though no fault of their own) don't actually know how <form> works!

Since Remix <Form> works identically to <form> (with a couple of extra goodies for optimistic UI etc.), we're going to brush up on plain ol' HTML forms, so you can learn both HTML and Remix at the same time.

HTML Form HTTP Verbs

Native forms support two HTTP verbs: GET and POST. Remix uses these verbs to understand your intent. If it's a GET, Remix will figure out what parts of the page are changing and only fetch the data for the changing layouts, and use the cached data for the layouts that don't change. When it's a POST, Remix will reload all data to ensure it captures the update from the server. Let's take a look at both.

HTML Form GET

A GET is just a normal navigation where the form data is passed in the URL search params. You use it for normal navigation, just like <a> except the user gets to provide the data in the search params through the form. Aside from search pages, its use with <form> is pretty rare.

Consider this form:

<form method="get" action="/search">
  <label>Search <input name="term" type="text" /></label>
  <button type="submit">Search</button>
</form>

When the user fills it out and clicks submit, the browser automatically serializes the form values into a URL search param string and navigates to the form's action with the query string appended. Let's say the user typed in "remix". The browser would navigate to /search?term=remix. If we changed the input to <input name="q"/> then the form would navigate to /search?q=remix.

It's the same behavior as if we had created this link:

<a href="/search?term=remix">Search for "remix"</a>

With the unique difference that the user got to supply the information.

If you have more fields, the browser will add them:

<form method="get" action="/search">
  <fieldset>
    <legend>Brand</legend>
    <label>
      <input name="brand" value="nike" type="checkbox" />
      Nike
    </label>
    <label>
      <input name="brand" value="reebok" type="checkbox" />
      Reebok
    </label>
    <label>
      <input name="color" value="white" type="checkbox" />
      White
    </label>
    <label>
      <input name="color" value="black" type="checkbox" />
      Black
    </label>
    <button type="submit">Search</button>
  </fieldset>
</form>

Depending on which checkboxes the user clicks, the browser will navigate to URLs like:

/search?brand=nike&color=black
/search?brand=nike&brand=reebok&color=white

HTML Form POST

When you want to create, delete, or update data on your website, a form post is the way to go. And we don't just mean big forms like a user profile edit page. Even "Like" buttons can be handled with a form.

Let's consider a "new project" form.

<form method="post" action="/projects">
  <label><input name="name" type="text" /></label>
  <label><textarea name="description"></textarea></label>
  <button type="submit">Create</button>
</form>

When the user submits this form, the browser will serialize the fields into a request "body" (instead of URL search params) and "POST" it to the server. This is still a normal navigation as if the user clicked a link. The difference is two-fold: the user provided the data for the server and the browser sent the request as a "POST" instead of a "GET".

The data is made available to the server's request handler, so you can create the record. After that, you return a response. In this case, you'd probably redirect to the newly-created project. A remix action would look something like this:

export async function action({
  request,
}: ActionFunctionArgs) {
  const body = await request.formData();
  const project = await createProject(body);
  return redirect(`/projects/${project.id}`);
}

The browser started at /projects/new, then posted to /projects with the form data in the request, then the server redirected the browser to /projects/123. While this is all happening, the browser goes into its normal "loading" state: the address progress bar fills up, the favicon turns into a spinner, etc. It's actually a decent user experience.

If you're newer to web development, you may not have ever used a form this way. Lots of folks have always done:

<form onSubmit={(event) => { event.preventDefault(); // good
luck! }} />

If this is you, you're going to be delighted when you see just how easy mutations can be when you just use what browsers (and Remix) have built in!

Remix Mutation, Start to Finish

We're going to build a mutation from start to finish with:

  1. JavaScript optional
  2. Validation
  3. Error handling
  4. Progressively-enhanced loading indicators
  5. Progressively-enhanced error display

You use the Remix <Form> component for data mutations the same way you use HTML forms. The difference is now you get access to pending form state to build a nicer user experience: like contextual loading indicators and "optimistic UI".

Whether you use <form> or <Form> though, you write the very same code. You can start with a <form> and then graduate it to <Form> without changing anything. After that, add in the special loading indicators and optimistic UI. However, if you're not feeling up to it, or deadlines are tight, just use a <form> and let the browser handle the user feedback! Remix <Form> is the realization of "progressive enhancement" for mutations.

Building the form

Let's start with our project form from earlier but make it usable:

Let's say you've got the route app/routes/projects.new.tsx with this form in it:

export default function NewProject() {
  return (
    <form method="post" action="/projects/new">
      <p>
        <label>
          Name: <input name="name" type="text" />
        </label>
      </p>
      <p>
        <label>
          Description:
          <br />
          <textarea name="description" />
        </label>
      </p>
      <p>
        <button type="submit">Create</button>
      </p>
    </form>
  );
}

Now add the route action. Any form submissions that are "post" will call your data "action". Any "get" submissions (<Form method="get">) will be handled by your "loader".

import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { redirect } from "@remix-run/node"; // or cloudflare/deno

// Note the "action" export name, this will handle our form POST
export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const project = await createProject(formData);
  return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
  // ... same as before
}

And that's it! Assuming createProject does what we want it to, that's all you have to do. Note that no matter what kind of SPA you may have built in the past, you always need a server-side action and a form to get data from the user. The difference with Remix is that's all you need (and that's how the web used to be, too.)

Of course, we started complicating things to try to create better user experiences than the default browser behavior. Keep going, we'll get there, but we won’t have to change any of the code we've already written to get the core functionality.

Form Validation

It's common to validate forms both client-side and server-side. It's also (unfortunately) common to only validate client-side, which leads to various issues with your data that we don't have time to get into right now. Point is, if you're validating in only one place, do it on the server. You'll find with Remix that's the only place you care to anymore (the less you send to the browser the better!).

We know, we know, you want to animate in nice validation errors and stuff. We'll get to that. But right now we're just building a basic HTML form and user flow. We'll keep it simple first, then make it fancy.

Back in our action, maybe we have an API that returns validation errors like this.

const [errors, project] = await createProject(formData);

If there are validation errors, we want to go back to the form and display them.

import { json, redirect } from "@remix-run/node"; // or cloudflare/deno

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const [errors, project] = await createProject(formData);

  if (errors) {
    const values = Object.fromEntries(formData);
    return json({ errors, values });
  }

  return redirect(`/projects/${project.id}`);
};

Just like useLoaderData returns the values from the loader, useActionData will return the data from the action. It will only be there if the navigation was a form submission, so you always have to check if you've got it or not.

import type { ActionFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData } from "@remix-run/react";

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  // ...
};

export default function NewProject() {
  const actionData = useActionData<typeof action>();

  return (
    <form method="post" action="/projects/new">
      <p>
        <label>
          Name:{" "}
          <input
            name="name"
            type="text"
            defaultValue={actionData?.values.name}
          />
        </label>
      </p>

      {actionData?.errors.name ? (
        <p style={{ color: "red" }}>
          {actionData.errors.name}
        </p>
      ) : null}

      <p>
        <label>
          Description:
          <br />
          <textarea
            name="description"
            defaultValue={actionData?.values.description}
          />
        </label>
      </p>

      {actionData?.errors.description ? (
        <p style={{ color: "red" }}>
          {actionData.errors.description}
        </p>
      ) : null}

      <p>
        <button type="submit">Create</button>
      </p>
    </form>
  );
}

Notice how we add defaultValue to all of our inputs. Remember, this is regular HTML <form>, so it's just normal browser/server stuff happening. We're getting the values back from the server so the user doesn't have to re-type what they had.

You can ship this code as-is. The browser will handle the pending UI and interruptions for you. Enjoy your weekend and make it fancy on Monday.

Graduate to <Form> and add pending UI

Let's use progressive enhancement to make this UX a bit more fancy. By changing it from <form> to <Form>, Remix will emulate the browser behavior with fetch. It will also give you access to the pending form data, so you can build pending UI.

import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import { useActionData, Form } from "@remix-run/react";

// ...

export default function NewProject() {
  const actionData = useActionData<typeof action>();

  return (
    // note the capital "F" <Form> now
    <Form method="post">{/* ... */}</Form>
  );
}

HOLD UP! If all you do is change your form to Form, you made the UX a little worse!

If you don't have the time or drive to do the rest of the job here, use <Form reloadDocument>. This lets the browser continue to handle the pending UI state (spinner in the favicon of the tab, progress bar in the address bar, etc.) If you simply use <Form> without implementing pending UI, the user will have no idea anything is happening when they submit a form.

We recommend always using capital-F Form, and if you want to let the browser handle the pending UI, use the <Form reloadDocument> prop.

Now let's add some pending UI so the user has a clue something happened when they submit. There's a hook called useNavigation. When there is a pending form submission, Remix will give you the serialized version of the form as a FormData object. You'll be most interested in the formData.get() method.

import { json, redirect } from "@remix-run/node"; // or cloudflare/deno
import {
  useActionData,
  Form,
  useNavigation,
} from "@remix-run/react";

// ...

export default function NewProject() {
  // when the form is being processed on the server, this returns different
  // navigation states to help us build pending and optimistic UI.
  const navigation = useNavigation();
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <fieldset
        disabled={navigation.state === "submitting"}
      >
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={
                actionData
                  ? actionData.values.name
                  : undefined
              }
            />
          </label>
        </p>

        {actionData && actionData.errors.name ? (
          <p style={{ color: "red" }}>
            {actionData.errors.name}
          </p>
        ) : null}

        <p>
          <label>
            Description:
            <br />
            <textarea
              name="description"
              defaultValue={
                actionData
                  ? actionData.values.description
                  : undefined
              }
            />
          </label>
        </p>

        {actionData && actionData.errors.description ? (
          <p style={{ color: "red" }}>
            {actionData.errors.description}
          </p>
        ) : null}

        <p>
          <button type="submit">
            {navigation.state === "submitting"
              ? "Creating..."
              : "Create"}
          </button>
        </p>
      </fieldset>
    </Form>
  );
}

Pretty slick! Now when the user clicks "Create", the inputs go disabled, and the submit button's text changes. The whole operation should be faster now too since there's just one network request happening instead of a full page reload (which involves potentially more network requests, reading assets from the browser cache, parsing JavaScript, parsing CSS, etc.).

We didn't do much with navigation on this page, but it's got all the information about the submission (navigation.formMethod, navigation.formAction, navigation.formEncType), as well as all the values being processed on the server on navigation.formData.

Animating in the Validation Errors

Now that we're using JavaScript to submit this page, our validation errors can be animated in because the page is stateful. First we'll make a fancy component that animates height and opacity:

function ValidationMessage({ error, isSubmitting }) {
  const [show, setShow] = useState(!!error);

  useEffect(() => {
    const id = setTimeout(() => {
      const hasError = !!error;
      setShow(hasError && !isSubmitting);
    });
    return () => clearTimeout(id);
  }, [error, isSubmitting]);

  return (
    <div
      style={{
        opacity: show ? 1 : 0,
        height: show ? "1em" : 0,
        color: "red",
        transition: "all 300ms ease-in-out",
      }}
    >
      {error}
    </div>
  );
}

Now we can wrap our old error messages in this new fancy component, and even turn the borders of our fields red that have errors:

export default function NewProject() {
  const navigation = useNavigation();
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <fieldset
        disabled={navigation.state === "submitting"}
      >
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={
                actionData
                  ? actionData.values.name
                  : undefined
              }
              style={{
                borderColor: actionData?.errors.name
                  ? "red"
                  : "",
              }}
            />
          </label>
        </p>

        {actionData?.errors.name ? (
          <ValidationMessage
            isSubmitting={navigation.state === "submitting"}
            error={actionData?.errors?.name}
          />
        ) : null}

        <p>
          <label>
            Description:
            <br />
            <textarea
              name="description"
              defaultValue={actionData?.values.description}
              style={{
                borderColor: actionData?.errors.description
                  ? "red"
                  : "",
              }}
            />
          </label>
        </p>

        <ValidationMessage
          isSubmitting={navigation.state === "submitting"}
          error={actionData?.errors.description}
        />

        <p>
          <button type="submit">
            {navigation.state === "submitting"
              ? "Creating..."
              : "Create"}
          </button>
        </p>
      </fieldset>
    </Form>
  );
}

Boom! Fancy UI without having to change anything about how we communicate with the server. It's also resilient to network conditions that prevent JS from loading.

Review

  • First we built the project form without JavaScript in mind. A simple form, posting to a server-side action. Welcome to 1998.

  • Once that worked, we used JavaScript to submit the form by changing <form> to <Form>, but we didn't have to do anything else!

  • Now that there was a stateful page with React, we added loading indicators and animation for the validation errors by simply asking Remix for the state of the navigation.

From your components perspective, all that happened was the useNavigation hook caused a state update when the form was submitted, and then another state update when the data came back. Of course, a lot more happened inside of Remix, but as far as your component is concerned, that's it. Just a couple of state updates. This makes it really easy to dress up any user flow.

See also

Docs and examples licensed under MIT