Posted: 1/25/2022, 4:54:24 AM
Viewed 1376 times
Simple useFetcher Remix Form Example
The basics of the browser form are often lost with modern React framworks. Remix harnesses the power of forms to deliver a fantastic user experience. useFetcher can be a little confusing at first but it is so powerful!
Let's create a simple contact form example in remix.
Form Setup
First, create contact.tsx
in your routes
folder. useFetcher has a Form attribute so we want to use contactForm.Form as our form.
export default function ContactPage() {
const ref = useRef<HTMLFormElement>(null);
const contactForm = useFetcher();
return (
<contactForm.Form ref={ref} action="/contact" method="post">
<h4>Nice to meet you. Let's chat!</h4>
</contactForm.Form>
);
}
Field Component
Next, let's make a Field component that makes it easy to add labels and errors to our input fields. You can just copy this component and use it in your project if you'd like.
export interface InputErrorProps {
id: string;
children?: string | null;
}
export function InputError({
children,
id,
}: InputErrorProps): JSX.Element | null {
if (!children) {
return null;
}
return (
<p role={"alert"} id={id} className="text-red-500 text-sm">
{children}
</p>
);
}
export interface FieldProps {
name: string;
type: string;
label: string;
disabled?: boolean;
error?: string | null;
}
export default function Field({
name,
type,
error,
label,
disabled,
}: FieldProps) {
const errorId = `${name}-error`;
const getInputByType = (type: string) => {
switch (type) {
case "textarea":
return <textarea disabled={disabled} name={name}></textarea>;
break;
default:
return <input disabled={disabled} name={name} type={type} />;
}
};
const input = getInputByType(type);
return (
<>
<label htmlFor={name}>{label}</label>
{error ? <InputError id={errorId}>{error}</InputError> : null}
{input}
</>
);
}
Add Fields
We are ready to add some fields and a submit button to our form. I am going to add name, email and message fields to the contact form.
export default function ContactPage() {
const ref = useRef<HTMLFormElement>(null);
const contactForm = useFetcher();
return (
<contactForm.Form ref={ref} action="/contact" method="post">
<h4>Nice to meet you. Let's chat!</h4>
<Field
name="name"
type="text"
label="Name"
/>
<Field
name="email"
type="email"
label="Email"
/>
<Field
name="message"
type="textarea"
label="Message"
/>
<button type="submit" className="underlined pt-1">Send</button>}
</contactForm.Form>
);}
Validation
From here, we start to unlock some of the remix magic. We want to write a handler for our form to validate the fields and submit an email. With remix, we can write this handler in an action function.
export type Fields = {
name: string;
email: string;
message: string;
};
export type Errors = Record<keyof Fields | "generalError", string | null>;
export type ActionData =
| { status: "success" }
| { status: "error"; errors: Errors };
function getErrorForFirstName(name: string | null) {
if (!name) return `Name is required`;
if (name.length > 60) return `Name is too long`;
return null;
}
function getErrorForEmail(email: string | null) {
if (!email) return `Email is required`;
if (!/^.+@.+\..+$/.test(email)) return `Email is not valid`;
return null;
}
function getErrorForMessage(message: string | null) {
if (!message) return `Message is required`;
return null;
}
export const action: ActionFunction = ({ request }: { request: Request }) => {
const requestText = await request.text();
const form = new URLSearchParams(requestText);
const fields: Fields = {
name: form.get("name") ?? "",
email: form.get("email") ?? "",
message: form.get("message") ?? "",
};
const errors: Errors = {
generalError: null,
name: getErrorForFirstName(fields.name),
email: getErrorForEmail(fields.email),
message: getErrorForMessage(fields.message),
};
let data: ActionData;
if (Object.values(errors).some((err) => err !== null)) {
data = { status: "error", errors };
return json(data, 400);
}
try {
// create a sendEmail function that uses the email provider of your choice
sendEmail(fields);
} catch (error: unknown) {
errors.generalError = getErrorMessage(error);
data = { status: "error", errors };
return json(data, 500);
}
data = { status: "success" };
return json(data);
};
We pull the form data by parsing the URL search params like so.
const requestText = await request.text();
const form = new URLSearchParams(requestText);
const fields: Fields = {
name: form.get("name") ?? "",
email: form.get("email") ?? "",
message: form.get("message") ?? "",
};
The sendEmail
function is left out of this tutorial for simplicity sake but you can create a sendEmail function with the email provider of your choice. If you're curious, I'm using mailgun in my personal contact form.
Now that we have some validation occuring in our action, we can alter our form to reflect that response. We can access the data from our useFetcher by accessing the data atribute.
Add the following variables to your contract page component.
const data = contactForm.type === "done" ? contactForm.data : null;
const success = data?.status === "success";
We can add an error to our field component by supplying any errors returned from the action. Add error and disabled prop to each of the fields. The disabled prop will now disable the form if the form is loading state or it has returned successfully.
<Field
name="name"
type="text"
label="Name"
error={data?.status === "error" ? data.errors.name : null}
disabled={contactForm.state === "loading" || success}
/>
The user should know their email has been sent successfully. Let's use the success variable we just created to render a success message in the place of our submit button if the email has been sent successfully.
{
success ? (
<p>Success! Your email has been sent.</p>
) : (
<button type="submit" className="underlined pt-1">
Send
</button>
);
}
I also wanted the form to reset after it was submitted, so I added a useEffect for that.
useEffect(() => {
if (ref.current && success) {
ref.current.reset();
}
}, [success])
Final Thoughts
And that's it! You've got a functional contact form with validation and user feedback in Remix. The full code for the contact route is below. Happy coding!
import { useEffect, useRef } from "react";
import { ActionFunction, useFetcher } from "remix";
import Field from "~/components/FormElements/Field";
import { json } from "remix";
import { sendEmail } from "../utils/mailgun.server";
export type Fields = {
name: string;
email: string;
message: string;
};
export type Errors = Record<keyof Fields | "generalError", string | null>;
export type ActionData =
| { status: "success" }
| { status: "error"; errors: Errors };
function getErrorForFirstName(name: string | null) {
if (!name) return `Name is required`;
if (name.length > 60) return `Name is too long`;
return null;
}
function getErrorForEmail(email: string | null) {
if (!email) return `Email is required`;
if (!/^.+@.+\..+$/.test(email)) return `Email is not valid`;
return null;
}
function getErrorForMessage(message: string | null) {
if (!message) return `Message is required`;
return null;
}
function getErrorMessage(error: unknown) {
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return "Unknown Error";
}
export const action: ActionFunction = async ({ request }: { request: Request }) => {
const requestText = await request.text();
const form = new URLSearchParams(requestText);
const fields: Fields = {
name: form.get("name") ?? "",
email: form.get("email") ?? "",
message: form.get("message") ?? "",
};
const errors: Errors = {
generalError: null,
name: getErrorForFirstName(fields.name),
email: getErrorForEmail(fields.email),
message: getErrorForMessage(fields.message),
};
let data: ActionData;
if (Object.values(errors).some((err) => err !== null)) {
data = { status: "error", errors };
return json(data, 400);
}
try {
// create a sendEmail function that uses the email provider of your choice
sendEmail(fields);
} catch (error: unknown) {
errors.generalError = getErrorMessage(error);
data = { status: "error", errors };
return json(data, 500);
}
data = { status: "success" };
return json(data);
};
export default function ContactPage() {
const ref = useRef<HTMLFormElement>(null);
const contactForm = useFetcher();
const data = contactForm.type === "done" ? contactForm.data : null;
const success = data?.status === "success";
useEffect(() => {
if (ref.current && success) {
ref.current.reset();
}
}, [success])
return (
<contactForm.Form ref={ref} action="/contact" method="post">
<h4>Nice to meet you. Let's chat!</h4>
<Field
name="name"
type="text"
label="Name"
error={data?.status === "error" ? data.errors.name : null}
disabled={contactForm.state === "loading" || success}
/>
<Field
name="email"
type="email"
label="Email"
error={data?.status === "error" ? data.errors.email : null}
disabled={contactForm.state === "loading" || success}
/>
<Field
name="message"
type="textarea"
label="Message"
error={data?.status === "error" ? data.errors.message : null}
disabled={contactForm.state === "loading" || success}
/>
{success ? <p>Success! Your email has been sent.</p>: <button type="submit" className="underlined pt-1">
Send
</button>}
</contactForm.Form>
);}