In the last post I created a login form using the Remix Form
component. I’m going to update it to use fetcher.Form
.
Login and registration forms might be an appropriate use case for fetcher.Form
. The Remix docs provide some guidance:
The primary criterion when choosing [between
Form
andfetcher
] is whether you want the URL to change or not:https://remix.run/docs/en/main/discussion/form-vs-fetcher
- URL Change Desired: When navigating or transitioning between pages, or after certain actions like creating or deleting records. This ensures that the user’s browser history accurately reflects their journey through your application.
- Expected Behavior: In many cases, when users hit the back button, they should be taken to the previous page. Other times the history entry may be replaced but the URL change is important nonetheless.
- No URL Change Desired: For actions that don’t significantly change the context or primary content of the current view. This might include updating individual fields or minor data manipulations that don’t warrant a new URL or page reload. This also applies to loading data with fetchers for things like popovers, combo boxes, etc.
For the case of login/registration forms, I’m not sure. I’m thinking the benefits of (Edit: Remix intercepts the form submission event. There’s no need to pass field values from the server back to the client, either with a fetcher
for this case are that it will prevent “negative mutations” (currently being handled by passing a fields
prop to the LoginForm
component), and that it will allow the form’s UI to be updated before submission. (I’m guessing on both of those.)Form
component, or with fetcher.Form
. Also, the UI can be updated prior to form submission either with Form
components, or with fetcher.Form
. See my next post for details. I don’t think there’s an advantage to using fetcher.Form
for a registration or login form.)
$ git checkout -b create_cookie_session_storage_use_fetcher
Maybe pass a fetcher
prop to the login form?
// app/routes/login.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useFetcher, FetcherWithComponents } from "@remix-run/react";
import LoginForm from "~/components/LoginForm";
import { createUserSession, login } from "~/services/session.server";
import type { LoginFetcher } from "~/components/LoginForm";
export const action = async ({ request }: ActionFunctionArgs) => {
const form = await request.formData();
const usernameOrEmail = form.get("usernameOrEmail");
const password = form.get("password");
if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
return json(
{
fieldError: null,
formError: "Form not submitted correctly.",
},
{ status: 400 }
);
}
let user, fieldError, formError;
const loginReaponse = await login({
usernameOrEmail: usernameOrEmail,
password: password,
});
if ("user" in loginReaponse) {
user = loginReaponse.user;
return createUserSession(user.id, "/");
}
if ("fieldError" in loginReaponse) {
fieldError = loginReaponse.fieldError;
} else {
fieldError = null;
formError = "Something's gone wrong";
}
return json(
{
fieldError: fieldError,
formError: formError,
},
{ status: 400 }
);
};
export default function Login() {
const fetcher: FetcherWithComponents<LoginFetcher> = useFetcher();
return (
<div>
<h1 className="text-2xl">Log In</h1>
<LoginForm fetcher={fetcher} />
</div>
);
}
// app/components/LoginForm.tsx
import type { FetcherWithComponents } from "@remix-run/react";
export interface FieldError {
key: string;
message: string;
}
export interface LoginFetcher {
fieldError?: FieldError | null;
formError?: string | null;
}
interface LoginProps {
fetcher: FetcherWithComponents<LoginFetcher>;
}
export default function LoginForm({ fetcher }: LoginProps) {
const fetcherData = fetcher.data;
let emailOrUsernameError = false,
emailOrUsernameErrorMessage,
passwordError = false,
passwordErrorMessage,
formError;
if (fetcherData) {
emailOrUsernameError =
fetcherData?.fieldError?.key === "usernameOrEmail" ? true : false;
emailOrUsernameErrorMessage = emailOrUsernameError
? fetcherData?.fieldError?.message
: null;
passwordError = fetcherData?.fieldError?.key === "password" ? true : false;
passwordErrorMessage = passwordError
? fetcherData?.fieldError?.message
: null;
formError = fetcherData?.formError;
}
return (
<div>
<fetcher.Form
className="flex flex-col max-w-80 border border-slate-400 p-3"
method="post"
action="/login"
>
<label htmlFor="username-or-email">Username or Email</label>
<input
className="border border-slate-600 px-1"
type="text"
id="username-or-email"
name="usernameOrEmail"
aria-invalid={emailOrUsernameError}
aria-errormessage={
emailOrUsernameErrorMessage ? emailOrUsernameErrorMessage : ""
}
/>
{emailOrUsernameErrorMessage && (
<p className="text-sm text-red-600">{emailOrUsernameErrorMessage}</p>
)}
<label htmlFor="password">Password</label>
<input
className="border border-slate-600 px-1"
type="password"
id="password"
name="password"
aria-invalid={passwordError}
aria-errormessage={passwordErrorMessage ? passwordErrorMessage : ""}
/>
{passwordErrorMessage && (
<p className="text-sm text-red-600">{passwordErrorMessage}</p>
)}
{formError && (
<div className="border border-red-500 p-2 my-2 rounded-sm text-sm">
{formError}
</div>
)}
<div className="mt-4">
<button className="border border-slate-600 w-16" type="submit">
Login
</button>
</div>
</fetcher.Form>
</div>
);
}
That works! It also simplified the code a bit – the values of the submitted form’s fields don’t have to be passed back to the component.
Check to see if the user exists before submitting the form
This is obnoxious, but really neat. It checks to see if a user can be found by username/email while they are inputting text into the form. I won’t add it to a production site’s login form, but might use a similar pattern somewhere else:
// app/routes/api.usernameOrEmailExists.tsx
// A resource route for checking if a user
// exists for a given username or email.
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { findUserByUsernameOrEmail } from "~/services/user";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const usernameOrEmail = url.searchParams.get("usernameOrEmail");
let user;
if (usernameOrEmail) {
user = await findUserByUsernameOrEmail(usernameOrEmail);
}
const usernameOrEmailExists = user ? true : false;
return json({ usernameOrEmailExists: usernameOrEmailExists });
};
// app/components/LoginForm.tsx
import type { FetcherWithComponents } from "@remix-run/react";
import debounce from "~/services/debounce";
export interface FieldError {
key: string;
message: string;
}
export interface LoginFetcher {
fieldError?: FieldError | null;
formError?: string | null;
usernameOrEmailExists?: boolean | null;
}
interface LoginProps {
fetcher: FetcherWithComponents<LoginFetcher>;
}
export default function LoginForm({ fetcher }: LoginProps) {
const fetcherData = fetcher.data;
let emailOrUsernameError = false,
emailOrUsernameErrorMessage,
passwordError = false,
passwordErrorMessage,
formError,
usernameOrEmailNotFound;
if (fetcherData) {
emailOrUsernameError =
fetcherData?.fieldError?.key === "usernameOrEmail" ? true : false;
emailOrUsernameErrorMessage = emailOrUsernameError
? fetcherData?.fieldError?.message
: null;
passwordError = fetcherData?.fieldError?.key === "password" ? true : false;
passwordErrorMessage = passwordError
? fetcherData?.fieldError?.message
: null;
formError = fetcherData?.formError;
usernameOrEmailNotFound = !fetcherData?.usernameOrEmailExists;
}
const handleInput = debounce((value: string) => {
fetcher.load(
`/api/usernameOrEmailExists?usernameOrEmail=${encodeURIComponent(value)}`
);
}, 500);
// Get the input's value, then call the debounced handleInput function
// avoids issues with React's event pooling. Look into that more
// before using in production.
const debouncedInputHandler = (event: React.FormEvent<HTMLInputElement>) => {
// Extract the value right away, so it's not accessed asynchronously
const usernameOrEmail = event.currentTarget.value;
handleInput(usernameOrEmail);
};
return (
<div>
<fetcher.Form
className="flex flex-col max-w-80 border border-slate-400 p-3"
method="post"
action="/login"
>
<label htmlFor="username-or-email">Username or Email</label>
<input
className="border border-slate-600 px-1"
type="text"
id="username-or-email"
name="usernameOrEmail"
onInput={debouncedInputHandler}
aria-invalid={emailOrUsernameError}
aria-errormessage={
emailOrUsernameErrorMessage ? emailOrUsernameErrorMessage : ""
}
/>
{emailOrUsernameErrorMessage ||
(usernameOrEmailNotFound === true && (
<p className="text-sm text-red-600">
{emailOrUsernameErrorMessage || "User not found."}
</p>
))}
<label htmlFor="password">Password</label>
<input
className="border border-slate-600 px-1"
type="password"
id="password"
name="password"
aria-invalid={passwordError}
aria-errormessage={passwordErrorMessage ? passwordErrorMessage : ""}
/>
{passwordErrorMessage && (
<p className="text-sm text-red-600">{passwordErrorMessage}</p>
)}
{formError && (
<div className="border border-red-500 p-2 my-2 rounded-sm text-sm">
{formError}
</div>
)}
<div className="mt-4">
<button className="border border-slate-600 w-16" type="submit">
Login
</button>
</div>
</fetcher.Form>
</div>
);
}
RegistrationForm
component
$ touch app/components/RegistrationForm.tsx
… 24 hours later.
(With the exception of not validating email addresses before allowing new users to log into the site), it works! But, I’m thinking that the reliance on useState
for persisting data about field validations is unnecessary:
// app/components/RegistrationForm.tsx
//...
export default function RegistrationForm({ fetcher }: RegistrationProps) {
const fetcherData = fetcher.data;
// `emailFormValue` and `usernameFormValue` are set so that both `email` and `username`
// can be passed to `checkEmailExists` and `checkUsernameExists`
const [emailFormValue, setEmailFormValue] = useState("");
const [usernameFormValue, setUsernameFormValue] = useState("");
// allows `passwordValid` to be used to set the `submitDisabled` variable
const [passwordValid, setPasswordValid] = useState(false);
//...
The code’s easy to understand though.
I’ll post the relevant code here without much comment, then start on version 3.
login.tsx
// app/routes/login.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
useFetcher,
FetcherWithComponents,
useLoaderData,
} from "@remix-run/react";
import LoginForm from "~/components/LoginForm";
import RegistrationForm from "~/components/RegistrationForm";
import { createUserSession, login } from "~/services/session.server";
import { registerUser } from "~/services/user";
import type { LoginFetcher } from "~/components/LoginForm";
import type {
FieldErrors,
RegistrationFetcher,
} from "~/components/RegistrationForm";
type LoginRegistrationFetcher = LoginFetcher | RegistrationFetcher;
const validateUsername = (username: string) => {
const valid = username.length >= 3;
const message = valid
? ""
: "Usernames must be at least three characters long.";
return { valid: valid, message: message };
};
const validateEmail = (email: string) => {
const valid = /\S+@\S+\.\S+/.test(email);
const message = valid ? "" : `${email} is not a valid email address.`;
return { valid: valid, message: message };
};
const validatePassword = (password: string) => {
// todo: validate max length too
const valid = password.length >= 8;
const message = valid ? "" : "Passwords must be at least 8 characters long.";
return { valid: valid, message: message };
};
// Used to conditionally render either Login or Registration form
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const action = url.searchParams.get("action");
const formType = action === "new_account" ? "register" : "login";
const initiateLoginFor = url.searchParams.get("username");
return json({ formType: formType, username: initiateLoginFor });
};
export const action = async ({ request }: ActionFunctionArgs) => {
const url = new URL(request.url);
const action = url.searchParams.get("action");
const formType = action === "new_account" ? "register" : "login"; // Determines which form to process
const form = await request.formData();
const usernameOrEmail = form.get("usernameOrEmail"); // Login form param
const password = form.get("password"); // Login and Registration form param
const email = form.get("email"); // Registration form param
const username = form.get("username"); // Registration form param
switch (formType) {
case "login":
if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
return json(
{
fieldError: null,
formError: "Form not submitted correctly.",
},
{ status: 400 }
);
}
let user, fieldError, formError;
const loginReaponse = await login({
usernameOrEmail: usernameOrEmail,
password: password,
});
if ("user" in loginReaponse) {
user = loginReaponse.user;
return createUserSession(user.id, "/");
}
if ("fieldError" in loginReaponse) {
fieldError = loginReaponse.fieldError;
} else {
fieldError = null;
formError = "Something's gone wrong";
}
return json(
{
fieldError: fieldError,
formError: formError,
},
{ status: 400 }
);
case "register":
let fieldErrors: FieldErrors = {};
if (
typeof email !== "string" ||
typeof username !== "string" ||
typeof password !== "string"
) {
return json(
{
formError: "Form not submitted correctly",
},
{ status: 400 }
);
}
const emailValidation = validateEmail(email);
if (!emailValidation.valid) {
fieldErrors["email"] = emailValidation.message;
}
const usernameValidation = validateUsername(username);
if (!usernameValidation.valid) {
fieldErrors["username"] = usernameValidation.message;
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
fieldErrors["password"] = passwordValidation.message;
}
if (Object.keys(fieldErrors).length > 0) {
return json(
{
fieldErrors: fieldErrors,
},
{
status: 400,
}
);
}
const registrationResponse = await registerUser({
email: email,
username: username,
password: password,
});
if ("user" in registrationResponse) {
return redirect(
`/login?username=${registrationResponse.user.username}`
);
}
// if this gets executed, something really has gone wrong
return json({
formError: "Something has gone wrong",
});
}
};
export default function Login() {
const { formType, username } = useLoaderData<typeof loader>();
const fetcher: FetcherWithComponents<LoginRegistrationFetcher> = useFetcher();
return (
<div>
{formType === "login" && (
<div>
<h1 className="text-2xl">Log In</h1>
<LoginForm fetcher={fetcher} username={username} />
</div>
)}
{formType === "register" && (
<div>
<h1 className="text-2xl">Register</h1>
<RegistrationForm fetcher={fetcher} />
</div>
)}
</div>
);
}
LoginForm component
// app/components/LoginForm.tsx
import type { FetcherWithComponents } from "@remix-run/react";
import { Link } from "@remix-run/react";
export interface FieldError {
key: string;
message: string;
}
export interface LoginFetcher {
fieldError?: FieldError | null;
formError?: string | null;
}
interface LoginProps {
fetcher: FetcherWithComponents<LoginFetcher>;
username?: string | null;
}
export default function LoginForm({ fetcher, username }: LoginProps) {
const fetcherData = fetcher.data;
const initiateLoginFor = username ? username : "";
let emailOrUsernameError = false,
emailOrUsernameErrorMessage,
passwordError = false,
passwordErrorMessage,
formError;
if (fetcherData) {
emailOrUsernameError =
fetcherData?.fieldError?.key === "usernameOrEmail" ? true : false;
emailOrUsernameErrorMessage = emailOrUsernameError
? fetcherData?.fieldError?.message
: null;
passwordError = fetcherData?.fieldError?.key === "password" ? true : false;
passwordErrorMessage = passwordError
? fetcherData?.fieldError?.message
: null;
formError = fetcherData?.formError;
}
return (
<div>
<fetcher.Form
className="flex flex-col max-w-80 border border-slate-400 p-3"
method="post"
action="/login"
>
<label htmlFor="username-or-email">Username or Email</label>
<input
className="border border-slate-600 px-1"
type="text"
id="username-or-email"
name="usernameOrEmail"
aria-invalid={emailOrUsernameError}
defaultValue={initiateLoginFor}
aria-errormessage={
emailOrUsernameErrorMessage ? emailOrUsernameErrorMessage : ""
}
/>
{emailOrUsernameErrorMessage && (
<p className="text-sm text-red-600">
{emailOrUsernameErrorMessage || "User not found."}
</p>
)}
<label htmlFor="password">Password</label>
<input
className="border border-slate-600 px-1"
type="password"
id="password"
name="password"
aria-invalid={passwordError}
aria-errormessage={passwordErrorMessage ? passwordErrorMessage : ""}
/>
{passwordErrorMessage && (
<p className="text-sm text-red-600">{passwordErrorMessage}</p>
)}
{formError && (
<div className="border border-red-500 p-2 my-2 rounded-sm text-sm">
{formError}
</div>
)}
<div className="mt-4">
<button className="border border-slate-600 w-16" type="submit">
Login
</button>
</div>
</fetcher.Form>
<Link
className="text-sm text-sky-700 hover:underline"
to="/login?action=new_account"
>
Register a new account
</Link>
</div>
);
}
I ran into trouble with state management in the registration form. The form checks the email and password fields to see if the supplied values are available. It also disables the “Register” button until the email, username, and password fields have passed some validation checks.
This is where things get weird:
// app/components/RegistrationForm.tsx
//...
const checkEmailExists = debounce(
(email: string, username: string | null) => {
if (validateEmailFormat(email)) {
let currentUsername = username ? username : "";
fetcher.load(
`/api/usernameOrEmailExists?email=${encodeURIComponent(
email
)}&username=${encodeURIComponent(currentUsername)}`
);
}
},
500
);
//...
In my initial implementation, the checkEmailExists
function was only passing the email address to the resource route, instead of passing the email address and username. There was (and still is) a checkUsernameExists
function that was passing only the username to a resource route. The problem was that loading either route’s data caused any error messages related to other form fields to disappear (because local variables don’t persist on re-render). To get around that, I started persisting values with useState
.
RegistrationForm component
// app/components/RegistrationForm.tsx
import type { FetcherWithComponents } from "@remix-run/react";
import { Link } from "@remix-run/react";
import { useState } from "react";
import debounce from "~/services/debounce";
const validateEmailFormat = (email: string) => /\S+@\S+\.\S+/.test(email);
export interface FieldErrors {
[key: string]: string | undefined;
}
export interface RegistrationFetcher {
fieldErrors?: FieldErrors | null;
formError?: string | null;
usernameExists?: boolean | null;
emailExists?: boolean | null;
}
interface RegistrationProps {
fetcher: FetcherWithComponents<RegistrationFetcher>;
}
export default function RegistrationForm({ fetcher }: RegistrationProps) {
const fetcherData = fetcher.data;
// `emailFormValue` and `usernameFormValue` are set so that both `email` and `username`
// can be passed to `checkEmailExists` and `checkUsernameExists`
const [emailFormValue, setEmailFormValue] = useState("");
const [usernameFormValue, setUsernameFormValue] = useState("");
// allows `passwordValid` to be used to set the `submitDisabled` variable
const [passwordValid, setPasswordValid] = useState(false);
let emailExists = false,
usernameExists = false,
submitDisabled = true,
fieldErrors: FieldErrors = {};
if (fetcherData) {
emailExists = fetcherData?.emailExists ?? false;
usernameExists = fetcherData?.usernameExists ?? false;
fieldErrors = fetcherData?.fieldErrors ?? {};
}
submitDisabled = Boolean(
emailExists ||
usernameExists ||
!emailFormValue ||
!usernameFormValue ||
!passwordValid
);
const checkEmailExists = debounce(
(email: string, username: string | null) => {
if (validateEmailFormat(email)) {
let currentUsername = username ? username : "";
fetcher.load(
`/api/usernameOrEmailExists?email=${encodeURIComponent(
email
)}&username=${encodeURIComponent(currentUsername)}`
);
}
},
500
);
const checkUsernameExists = debounce(
(username: string, email: string | null) => {
if (username.length > 2) {
const currentEmail = email ? email : "";
fetcher.load(
`/api/usernameOrEmailExists?username=${encodeURIComponent(
username
)}&email=${encodeURIComponent(currentEmail)}`
);
}
},
500
);
// Get the input's value, then call the handleInput function
// avoids issues with React's event pooling. Look into that more
// before using in production.
const emailInputHandler = (event: React.FormEvent<HTMLInputElement>) => {
const email = event.currentTarget.value;
setEmailFormValue(email);
checkEmailExists(email, usernameFormValue);
// remove the `emailExists` warning when new text is entered
if (email && emailExists) {
emailExists = false;
}
};
const usernameInputHandler = (event: React.FormEvent<HTMLInputElement>) => {
const username = event.currentTarget.value;
setUsernameFormValue(username);
checkUsernameExists(username, emailFormValue);
// remove the `usernameExists` warning when new text is entered
if (username && usernameExists) {
usernameExists = false;
}
};
const passwordInputHandler = (event: React.FormEvent<HTMLInputElement>) => {
const password = event.currentTarget.value;
setPasswordValid(password.length >= 8);
};
return (
<div>
<fetcher.Form
className="flex flex-col max-w-60 border border-slate-400 p-3"
method="post"
action="/login?action=new_account"
>
<label htmlFor="email">Email</label>
<input
className="border border-slate-600 px-1"
type="email"
id="email"
name="email"
onInput={emailInputHandler}
minLength={5}
maxLength={320}
aria-invalid={Boolean(fieldErrors?.email)}
aria-errormessage={fieldErrors?.email ? fieldErrors.email : ""}
/>
<div className="min-h-6">
{emailExists && (
<p className="text-sm text-red-600">
Email address taken. Maybe you already have an account?
</p>
)}
{fieldErrors?.email && (
<p className="text-sm text-red-600">{fieldErrors.email}</p>
)}
</div>
<label htmlFor="username">Username</label>
<input
className="border border-slate-600 px-1"
type="text"
id="username"
name="username"
onInput={usernameInputHandler}
minLength={3}
maxLength={60}
aria-invalid={Boolean(fieldErrors?.username)}
aria-errormessage={fieldErrors?.username ? fieldErrors.username : ""}
/>
<div className="min-h-6">
{usernameExists && (
<p className="text-sm text-red-600">Username taken.</p>
)}
{fieldErrors?.username && (
<p className="text-sm text-red-600">{fieldErrors.username}</p>
)}
</div>
<label htmlFor="password">
Password <span className="text-sm">(min 8 characters)</span>
</label>
{/* note that 2FA is available but not enforced on the site */}
<input
className="border border-slate-600 px-1"
type="password"
id="password"
name="password"
onInput={passwordInputHandler}
minLength={8}
maxLength={255}
aria-invalid={Boolean(fieldErrors?.password)}
aria-errormessage={fieldErrors?.password ? fieldErrors.password : ""}
/>
<div className="min-h-3">
{fieldErrors?.password && (
<p className="text-sm text-red-600">{fieldErrors.password}</p>
)}
</div>
<div className="mt-4">
<button
disabled={submitDisabled}
className={`border border-sky-900 text-slate-50 px-2 py-1 rounded-sm ${
submitDisabled
? "bg-slate-500 cursor-default"
: "bg-sky-800 cursor-pointer"
}`}
type="submit"
>
Register
</button>
</div>
</fetcher.Form>
<Link className="text-sm text-sky-700 hover:underline" to="/login">
Log in
</Link>
</div>
);
}
For future reference: