Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active December 31, 2021 17:48
Show Gist options
  • Save ryanflorence/053d98fdac9ba68e323f909ddbeaa64b to your computer and use it in GitHub Desktop.
Save ryanflorence/053d98fdac9ba68e323f909ddbeaa64b to your computer and use it in GitHub Desktop.
meta
title
Mutations with Actions and Form | Remix

import { Link } from "react-router-dom";

Mutations with Actions and <Form>

Data 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.

Plain HTML Forms

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

Since Remix <Form> works identically to <form> (with a couple 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. Let's take a look at each.

HTML Form GET

A GET is just a normal navigation where the data is passed in the URL search params. You use it for normal navigation that depends on user input. Aside from search pages, it's use with form is pretty rare.

Consider this form:

<form method="get" action="/search">
  <label><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 from into a URL search param string and navigates to the form's action attribute with the query string. 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.

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" and "POST" it to the server. This is still a normal navigation as if the user clicked a link. The difference is twofold: 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 create the record. After that you return a response. In this case you'd probably redirect to the newly created project. Here's some pseudo express.js code to illustrate what the /projects route might look like (this is not how you do it in Remix):

app.post("/projects", async (req, res) => {
  let project = await createProject(req.body);
  res.redirect(`/projects/${project.id}`);
});

The browser started at /projects/new, then posted to /projects with the form data in the request, then the server redirect the browser to /projects/123. The while the browser goes into it's normal "loading" state. The address progress bar fills up, the favicon turns into a spinner, etc. It's actually a solid 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 onSubmit={() => { fetch(...) }} and dealt with it all in JavaScript. 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!

A mutation in Remix from 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 us 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.js with this form in it:

<form method="post" action="/projects">
  <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 make a data route that will handle the action somewhere like loaders/routes/projects/new.js. Any form submits that aren't "get" submits will call your data "action", any "get" requests (links, and the rare <form method="get">) will be handled by your "loader".

const { parseFormBody, redirect } = require("@remix-run/data");

// Note the "action" export name, this will handle our form POST
exports.action = async ({ request }) => {
  // this helper will parse the form body on hte server
  let newProject = parseFormBody(request);
  let project = await createProject(newProject);
  return redirect(`/projects/${project.id}`);
};

And that's it! Assuming createProject does what we want it to, that's the core functionality.

Always Return a Redirect from Actions

Remix requires you to return a redirect from actions. We're fixing a longstanding issue with web development that browsers can't fix on their own. You've seen the alerts on websites like:

Don't click the back button or you will be charged twice!

Or

Please do not click back in your browser or you will book another flight!

The right thing to do is redirect from the "POST" so that when the user clicks back it goes to the page with the form, not the action that charges their credit card! Because browsers don't own the server, their only choice when the user clicks back is to repost the form.

Remix forces you to redirect from actions so that this bug never makes it into your app.

Form Validation

It's common to validate forms both clientside and serverside. It's also (unfortunately) common to only validate clientside, which leads to various issues with your data that we don't have time to get into right now. Point is, if your validating in only one place, do it on the server.

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 data action, maybe we have an API that returns validation errors like this.

let [errors, project] = await createProject(newProject);

If there are validation errors, we want to go back to the form and display them. If enabled, Remix sends a session object to your loaders and actions, we can use that to store the form validation errors.

const { parseFormBody } = require("@remix-run/data");

exports.action = async ({ request, session }) => {
  let newProject = await parseFormBody(request);
  let [errors, project] = await createProject(newProject);

  if (errors) {
    // session.flash puts a value in the session that can only be read on the
    // very next request. Here we put both the errors and the newProject values
    // to be read later in the component
    session.flash("failedSubmit", { errors, values: newProject });
    return redirect(`/projects`);
  }

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

exports.loader = () => {
  // we'll be back here in a minute
};

After we redirect from the validation errors, we end up back in this same data route, only this time it's a "GET", so our exports.loader will get called. Let's read the session data and send it to the form:

exports.loader = ({ request, session }) => {
  return session.get("failedSubmit") || null;
};

Now we can display the validation errors and the previous values in our UI with useRouteData().

Notice how we add defaultValue to all of our inputs. Remember, this is still a <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.

function NewProject() {
  let failedSubmit = useRouteData();

  return (
    <form method="post" action="/projects">
      <p>
        <label>
          Name:{" "}
          <input
            name="name"
            type="text"
            defaultValue={failedSubmit ? failedSubmit.values.name : undefined}
          />
        </label>
      </p>
      {failedSubmit && failedSubmit.errors.name && (
        <p style={{ color: "red" }}>{failedSubmit.errors.name}</p>
      )}

      <p>
        <label>
          Description:
          <br />
          <textarea
            name="description"
            defaultValue={
              failedSubmit ? failedSubmit.values.description : undefined
            }
          />
        </label>
      </p>
      {failedSubmit && failedSubmit.errors.description && (
        <p style={{ color: "red" }}>{failedSubmit.errors.description}</p>
      )}

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

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 and then give you access to the pending form information to build pending UI.

import { Form, useRouteData } from "@remix-run/react";

function NewProject() {
  let failedSubmit = useRouteData();

  return (
    // note the capital "F" <Form> now
    <Form method="post" action="/projects">
      {/* ... */}
    </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, leave it as <form> so that the browser handles 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.

Now let's add some pending UI so the user has a clue something happened when they submit. There's a hook called usePendingForm. When there is a pending form submit, 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 { Form, useRouteData, usePendingForm } from "@remix-run/react";

function NewProject() {
  let failedSubmit = useRouteData();

  // when the form is being processed on the server, this returns the same data
  // that was sent. When the submit is complete, this will return `undefined`.
  let pendingForm = usePendingForm();

  return (
    <form method="post" action="/projects">
      {/* wrap all our elements in a fieldset
          so we can disable them all at once */}
      <fieldset disabled={!!pendingForm}>
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={failedSubmit ? failedSubmit.values.name : undefined}
            />
          </label>
        </p>
        {failedSubmit && failedSubmit.errors.name && (
          <p style={{ color: "red" }}>{failedSubmit.errors.name}</p>
        )}

        <p>
          <label>
            Description:
            <br />
            <textarea
              name="description"
              defaultValue={
                failedSubmit ? failedSubmit.values.description : undefined
              }
            />
          </label>
        </p>
        {failedSubmit && failedSubmit.errors.description && (
          <p style={{ color: "red" }}>{failedSubmit.errors.description}</p>
        )}

        <p>
          <button type="submit">
            {/* and a little bit of pending UI */}
            {pendingForm ? "Creating..." : "Create"}
          </button>
        </p>
      </fieldset>
    </form>
  );
}

Pretty slick! Now when the user clicks submit, 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 pendingForm, on this page, but you can ask for values from the object while it's pending like pendingForm.data.get("name") or pendingForm.data.get("description"). You can also see the method used with pendingForm.method.

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 its height and opacity:

function ValidationMessage({ errorMessage, isPending }) {
  let [show, setShow] = React.useState(!!error);

  React.useEffect(() => {
    requestAnimationFrame(() => {
      let hasError = !!errorMessage;
      setShow(hasError && !isPending);
    });
  }, [error, isPending]);

  return (
    <div
      style={{
        opacity: show ? 1 : 0,
        height: show ? "1em" : 0,
        color: "red",
        transition: "all 300ms ease-in-out",
      }}
    >
      {errorMessage}
    </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:

function NewProject() {
  let failedSubmit = useRouteData();
  let pendingForm = usePendingForm();

  return (
    <form method="post" action="/projects">
      <fieldset disabled={!!pendingForm}>
        <p>
          <label>
            Name:{" "}
            <input
              name="name"
              type="text"
              defaultValue={failedSubmit ? failedSubmit.values.name : undefined}
              style={{
                borderColor:
                  failedSubmit && failedSubmit.errors.name ? "red" : "",
              }}
            />
          </label>
        </p>
        <Validation errorMessage={failedSubmit && failedSubmit.errors.name} />

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

        <p>
          <button type="submit">
            {pendingForm ? "Creating..." : "Create"}
          </button>
        </p>
      </fieldset>
    </form>
  );
}

Boom! Fancy UI!

Optimistic UI, Pending Delete Indication, and more

Something the previous example doesn't illustrate well is "optimistic UI". The usePendingForm().data object, as mentioned before, contains the values of the form that's being submit. You can use that to build an "optimistic UI" while the record is being created on the server.

Consider a little todo list. You can optimistically show the new todo before it's even saved to the database.

Even single buttons that perform data mutations can be modeled as <Form> and data actions. For example, "Like" and "Delete" buttons.

Check out this sample Todo app component that uses all the tricks we've just learned about.

Here's the component route:

export default function Todos() {
  let { todos } = useRouteData();
  let pendingForm = usePendingForm();

  let state = !pendingForm
    ? "idle"
    : pendingForm.method === "post"
    ? "creating"
    : pendingForm.method === "delete"
    ? "deleting"
    : throw new Error("unexpected pending form method");

  let showErrorTodo = state === "idle" && error;

  let pendingTodo = pendingForm
    ? Object.fromEntries(pendingForm.data)
    : undefined;

  return (
    <div>
      <h1>Todos</h1>
      <Form method="post">
        <input type="text" name="name" />
      </Form>

      <ul>
        {showErrorTodo && (
          <li>
            <span style={{ opacity: 0.5 }}>{error.name}</span>{" "}
            <span style={{ color: "red" }}>{error.message}</span>
          </li>
        )}

        {/* Optimistic UI */}
        {state === "creating" && (
          <li style={{ opacity: 0.5 }}>{pendingTodo.name}</li>
        )}

        {todos.map((todo: Todo) => (
          <li
            key={todo.id}
            style={{
              opacity:
                // pending delete indicator
                state === "deleting" && pendingTodo.id === todo.id ? 0.25 : 1,
            }}
          >
            {todo.name}{" "}
            <DeleteButton
              id={todo.id}
              disabled={state === "deleting" || state === "creating"}
            />
          </li>
        ))}
      </ul>
    </div>
  );
}

// Visually it's just a button, but it's a form since it's a mutation.
function DeleteButton({ id, disabled, ...props }) {
  return (
    <Form replace method="delete" style={{ display: "inline" }}>
      {/* hidden inputs send the action information we need to delete */}
      <input type="hidden" name="id" value={id} />
      <button disabled={disabled} {...props}>
        <TrashIcon />
      </button>
    </Form>
  );
}

And the data route:

import {
  json,
  parseFormBody,
  redirect,
  getCookieSession,
} from "@remix-run/data";
import { readTodos, createTodo, deleteTodo } from "../models/todo";

exports.loader = async ({ request }) => {
  let session = getCookieSession(request);
  let todos = await readTodos();
  let error = session.get("error") || null;
  return json({ todos, error });
};

exports.action = async ({ request }) => {
  let body = await parseFormBody(request);
  switch (request.method) {
    case "post": {
      let [_, error] = await createTodo(body.name);
      if (error) {
        let session = getCookieSession(request);
        session.flash("error", error);
      }
      return redirect("/todos");
    }
    case "delete": {
      await deleteTodo(body.id);
      return redirect("/todos");
    }
    default: {
      throw new Error(`Unknown method! ${request.method}`);
    }
  }
};

Review

First we built the project form without JavaScript in mind. A simple form, posting to a data action.

Once that worked, we use JavaScript to submit the form by changing <form> to <Form>.

Now that there was a stateful page with React, we added loading indicators and animation for the validation errors.

From your components perspective, all that happend was the usePendingForm hook caused a state update when the form was submit, and then another state update when the data came back in useRouteData() and usePendingForm() no longer returned anything. Of course, a lot more happened inside of Remix, but as far as your component is concerned that's it. Just a couple state updates. This makes it really easy to dress up any user flow involving mutations.

See also

  • Form
  • usePendingLocation
  • parseFormBody
  • Sessions
  • Actions
  • Loaders
@kentcdodds
Copy link

kentcdodds commented Nov 23, 2020

👋

- // this helper will parse the form body on hte server
+ // this helper will parse the form body on the server

In case it's unclear: s/hte/the

@kentcdodds
Copy link

Any thoughts on using fancy newish JS features?

- defaultValue={failedSubmit ? failedSubmit.values.name : undefined}
+ defaultValue={failedSubmit?.values.name}
-    {failedSubmit && failedSubmit.errors.description && (
+    {failedSubmit?.errors.description && (

... etc :)

@kentcdodds
Copy link

HOLD UP! If all you do is change your

to , you made the UX a little worse.

I wonder whether there's anything Remix can do to warn us of this situation in the devtools. Like, if I submit a <Form> and there's no components using usePendingForm, then we could get a log in the console or something?

@kentcdodds
Copy link

kentcdodds commented Nov 23, 2020

ValidationMessage accepts an isPending, but that prop isn't ever passed. I'm guessing it should be isPending={!!pendingForm}?

It also accepts an errorMessage prop, but the state is initialized with !!error.

@kentcdodds
Copy link

The Delete example uses replace on the Form, but that prop hasn't been explained. A code comment would probably be sufficient.

@kentcdodds
Copy link

Earlier in the docs, there's a reference to session as one of the values I get in my loader, but then later it's using getCookieSession instead.

@kentcdodds
Copy link

kentcdodds commented Nov 23, 2020

- caused a state update when the form was submit, 
+ caused a state update when the form was submitted, 

@jaredpalmer
Copy link

jaredpalmer commented Nov 24, 2020

Made an example of how to mix client and server validation and make conditional logic significantly easier via Formik.

The benefit of this approach is that you can have both inline feedback as the user types or after they blur a field as well as control state on complex forms.

import React from "react";
import ReactDOM from "react-dom";
import { useFormik } from "formik";
import * as Yup from "yup";

const Example = () => {
  let ref = React.useRef();
  let failedSubmit = useRouteData();
  let pendingForm = usePendingForm();
  const { getFieldProps, errors, touched, values, handleSubmit } = useFormik({
    initialValues: {
      username: "",
      type: "",
      company: "",
      ...failedSubmit?.values // if server changes values, override the client
    },
    validationSchema: Yup.object({
      username: Yup.string().required(),
      type: Yup.string().required().oneOf(["buyer", "seller"])
    }),
    onSubmit: async (values) => {
      // This only runs if client-side validation is successful and there are no errors
      ref.current.submit();
    },
    initialErrors: failedSubmit?.errors // instantiate server errors into Formik if they exist
  });
  return (
    <div>
      <form
        ref={ref}
        onSubmit={handleSubmit}
        method="post"
        action="https://httpbin.org/post"
      >
        <fieldset disabled={!!pendingForm}>
          <div>
            <label htmlFor="username">Username</label>
            <input {...getFieldProps("username")} />
            {errors.username && touched.username && (
              <div>{errors.username}</div>
            )}
          </div>
          <div>
            <label htmlFor="type">Type</label>
            <select {...getFieldProps("type")}>
              <option value="">Select type</option>
              <option value="buyer">Buyer</option>
              <option value="seller">Seller</option>
            </select>
            {errors.type && touched.type && <div>{errors.type}</div>}
          </div>
          {values.type === "seller" ? (
            <div>
              <label htmlFor="company">Company</label>
              <input {...getFieldProps("company")} />
              {errors.company && touched.company && <div>{errors.company}</div>}
            </div>
          ) : null}
          <button type="submit">Submit</button>
        </fieldset>
      </form>
    </div>
  );
};

ReactDOM.render(<Example />, document.getElementById("root"));

https://codesandbox.io/s/zen-haze-3csx6?file=/index.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment