Creating an entry form
Build the frontend form and define an action for our first user flow.
19 lessons · 5:00:25
- 1Scaffolding a new app12:31
- 2Creating an entry form22:49
- 3Saving new entries20:44
- Adding pending UI15:12
- Loading entries8:22
- Displaying entries by week24:09
- Creating the edit page13:43
- Editing entries9:47
- Making the entry form reusable10:20
- Deleting entries10:25
- Adding an authenticated state23:43
- Adding a logout button6:48
- Customizing the public UI5:01
- Securing our app12:58
- Customizing the error pages13:19
- Adding error messages to the login form13:37
- Mobile redesign41:57
- Desktop redesign18:46
- Deploying our app16:14
Video resources
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
$199one-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
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
- New videos added regularly
- Refactoring videos on React
- Private Discord
- Summaries with code
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!