Creating an entry form

Creating an entry form

Build the frontend form and define an action for our first user flow.

Let's build our app's first user flow: creating a new journal entry.

Since we'll need a form, let's start by adding Tailwind's official forms plugin, which makes styling forms with Tailwind more predictable.

Adding the @tailwindcss/forms plugin

Let's install the official @tailwindcss/forms plugin:

npm install -D @tailwindcss/forms

and add it to our plugins array in tailwind.config.js:

// tailwind.config.js
const colors = require("tailwindcss/colors");

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./app/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {
      colors: {
        gray: colors.neutral,
      },
    },
  },
  plugins: [require("@tailwindcss/forms")],
};

Now we're ready to add the form!

Creating the Entry form

We'll start by making a form where we can enter the entry's date, category, and text:

<div className="my-8 border p-3">
  <form>
    <p className="italic">Create an entry</p>

    <div className="mt-4">
      <div>
        <input type="date" name="date" className="text-gray-700" />
      </div>

      <div className="mt-2 space-x-6">
        <label>
          <input className="mr-1" type="radio" name="category" value="work" />
          Work
        </label>
        <label>
          <input
            className="mr-1"
            type="radio"
            name="category"
            value="learning"
          />
          Learning
        </label>
        <label>
          <input
            className="mr-1"
            type="radio"
            name="category"
            value="interesting-thing"
          />
          Interesting thing
        </label>
      </div>

      <div className="mt-2">
        <textarea
          name="text"
          className="w-full text-gray-700"
          placeholder="Write your entry..."
        />
      </div>

      <div className="mt-1 text-right">
        <button
          className="bg-blue-500 px-4 py-1 font-medium text-white"
          type="submit"
        >
          Save
        </button>
      </div>
    </div>
  </form>
</div>

The forms plugin normalizes the input styles across browsers and gives us sensible defaults around padding and borders, and makes things like focus states easier to style.

Now that we have our form, let's talk about how to get the data to our backend.

Using Remix's Form component

Now that we have the frontend of our form wired up, how do we get the data from the form back into Remix, and in a way that we can eventually persist it to our database?

If you've worked with React before, this is the point where you might be used to adding some React state, wiring up an event handler to the form's onSubmit event, calling preventDefault() to prevent the browser's default form submission behavior, and then using the values from state to hit an API endpoint:

function Index() {
  let [date, setDate] = useState();

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();

        // Make an API request with `date` and other values
      }}
    >
      <p>Create an entry</p>

      <input
        value={date}
        onChange={setDate}
        type="date"
        name="date"
        className="text-gray-700"
      />

      {/* ...rest of form */}

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

But we're not going to do any of this.

The philosophy in Remix is to embrace the declarative nature of HTML and HTTP. If we model our mutation using a plain HTML form, that form already has the values our user entered right inside of it, and the browser already knows how to turn that form into an HTTP request that includes that data. All Remix needs to do is piggyback off of the browser's default behavior, and route the request to a server-side action that we define.

To wire up Remix to our HTML form, the only change we need to make is to swap out our HTML <form> element with Remix's <Form> component:

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

export default function Index() {
  return <Form>{/* ... */}</Form>;
}

If we save this and submit the form, we'll see that all the data we entered has been serialized into the URL as a query string.

This is because the default method for an HTML <form> is GET. So the browser is serializing our form into a new GET request, which puts the form data into the URL, since GET requests don't have a request body.

GET requests make sense for fetching resources, but for this flow we want to create a resource. In HTTP, creating a resource is modeled as a POST request.

So, let's update our form's method to be "post":

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

export default function Index() {
  return <Form method="post">{/* ... */}</Form>;
}

Now when we submit the form, we see an error:

Error: Route "routes/index" does not have an action, but you're trying to submit to it. To fix this, please add an action function to the route.

Remix is telling us it's successfully routing our form submission, but we haven't defined an action function to handle the request yet.

We can add an action by exporting a function called action right next to our Index route's page component:

// app/routes/index.tsx
import { Form } from "@remix-run/react";

export function action() {
  console.log("this is the action");
}

export default function Index() {
  return <Form method="post">{/* ... */}</Form>;
}

Let's try saving again. We see the log in our server console, but we also see a new error:

Error: You defined an action for route "routes/index", but didn't return anything from our action function. Please return a value or null.

Let's just return null for now:

export function action() {
  console.log("this is the action");

  return null;
}

Now when we submit the form, we see our log, and we don't have an error anymore. If we open up the DevTool's Network tab, we'll actually see that Remix is making a fetch request. Remix has turned our action into an API endpoint and routed the form submission to it, all on our behalf.

So our app is still a full client-side SPA powered by React, but Remix has taken care of all the wiring for us. And all we had to do is describe our mutation using the semantics of HTTP and HTML forms.

Pretty amazing!

Redirecting from the action

Currently we see ?index being appended to the URL after we submit our form. This is because Remix's <Form> component expects its corresponding action to redirect the user to a new URL after a submission – since this is the typical behavior of an HTML <form>.

In our case, since our form is on the homepage and we'll see the new entries below, we don't need a redirect after submission. We'll see how Remix's fetchers are a better fit for forms that don't involve a navigation soon, but for now, we can just update our action to return a redirect right back to the homepage to avoid the ?index showing up in the URL:

import { redirect } from "@remix-run/node";

export function action() {
  console.log("this is the action");

  return redirect("/");
}

Now when we submit, our URL is clean.

Working with an action's FormData

Now that our <Form method="post"> is wired up, how do we access the form's data in our server-side action?

Actions get a few parameters passed to them, one of which is the request:

import { ActionArgs, redirect } from "@remix-run/node";

export function action({ request }: ActionArgs) {
  console.log(request);

  return redirect("/");
}

If we log the request, we'll see everything about the browser's form request in this object: headers, cookies, the method used, the request body, and so on.

In our case, we're interested in the formData property. This is a method that returns a promise, so if turn our action into an async function and await it, we'll see the data from our form:

import { ActionArgs, redirect } from "@remix-run/node";

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();

  console.log(formData);

  return redirect("/");
}

This is a FormData object, which uses JavaScript's native FormData interface. Something you'll learn as we go through the course is that Remix relies heavily on native web APIs whenever it can, both for its server code and for its client code. This will benefit you the more you build with Remix, because you'll be able to carry the knowledge you learn about the web's native APIs with you for the rest of your career as a web developer.

To see the raw values of a FormData instance, we can use Object.fromEntries:

import { ActionArgs, redirect } from "@remix-run/node";

export async function action({ request }: ActionArgs) {
  let formData = await request.formData();
  let json = Object.fromEntries(formData);

  console.log(json)

  return redirect("/");
}

Now when we submit our form and check our server console, we'll see the data in an object right in our backend action, ready for us to persist it!

Links

Buy Ship an app with Remix

Buy the course

$199
one-time payment

Get everything in Ship an app with Remix.

  • 5+ hours of video
  • 19 lessons
  • Private Discord
  • Summaries with code
  • Unlimited access to course materials

Lifetime membership

$349
Access all coursesone-time payment

Get lifetime access to every Build UI course, including Ship an app with Remix, forever.

  • Access to all five Build UI courses
  • Full access to all future Build UI courses
  • Summaries with code
  • Video downloads
  • Working code demos
  • Private Discord

What's included

Stream or download every video

Watch every lesson directly on Build UI, or download them to watch offline at any time.

Live code demos

Access to a live demo of each lesson that runs directly in your browser.

Private Discord

Chat with Sam, Ryan and other Build UI members about the lessons – or anything else you're working on – in our private server.

Video summaries with code snippets

Quickly reference a lesson's material with text summaries and copyable code snippets.

Source code

Each lesson comes with a GitHub repo that includes a diff of the source code.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!