Authentication: fetcher.Form login form

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 and fetcher] is whether you want the URL to change or not:

  • 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.
https://remix.run/docs/en/main/discussion/form-vs-fetcher

For the case of login/registration forms, I’m not sure. I’m thinking the benefits of 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.) (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 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: