Form Patterns

Common form patterns in Plainform using React Hook Form, Zod validation, and Clerk authentication

Form patterns in Plainform use React Hook Form for state management, Zod for validation, and integrate seamlessly with Clerk for authentication flows.

Form Stack

  • React Hook Form: Form state and validation
  • Zod: Schema validation with TypeScript inference
  • @hookform/resolvers: Connects Zod schemas to React Hook Form
  • Clerk: Authentication API integration
  • Sonner: Toast notifications for errors

Basic Form Pattern

Setup

@/components/MyForm.tsx
'use client';

import { useForm, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Button } from '@/components/ui/Button';

// Define schema
const formSchema = z.object({
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

type FormData = z.infer<typeof formSchema>;

export function MyForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormData>({
    mode: 'onTouched',
    resolver: zodResolver(formSchema),
  });

  const onSubmit: SubmitHandler<FormData> = async (data) => {
    // Handle form submission
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="email">Email</Label>
        <Input
          {...register('email')}
          id="email"
          type="email"
          placeholder="you@example.com"
          errorMessage={errors.email?.message}
          isInvalid={!!errors.email}
          disabled={isSubmitting}
        />
      </div>

      <div className="flex flex-col gap-2">
        <Label htmlFor="password">Password</Label>
        <Input
          {...register('password')}
          id="password"
          type="password"
          placeholder="●●●●●●●●"
          errorMessage={errors.password?.message}
          isInvalid={!!errors.password}
          disabled={isSubmitting}
        />
      </div>

      <Button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </Button>
    </form>
  );
}

Clerk Authentication Pattern

Sign In Form

@/components/user/SignInForm.tsx
'use client';

import { useSignIn } from '@clerk/nextjs';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { isClerkAPIResponseError } from '@clerk/nextjs/errors';
import type { ClerkAPIError } from '@clerk/types';

export function SignInForm() {
  const { signIn, setActive, isLoaded } = useSignIn();
  const router = useRouter();
  const {
    register,
    handleSubmit,
    setError,
    formState: { errors, isSubmitting },
  } = useForm<IFormData>({
    resolver: zodResolver(signInSchema),
  });

  const onSubmit: SubmitHandler<IFormData> = async (data) => {
    if (!isLoaded) return;

    try {
      const result = await signIn.create({
        identifier: data.email,
        password: data.password,
      });

      if (result.status === 'complete') {
        await setActive({ session: result.createdSessionId });
        router.push('/');
      }
    } catch (err: unknown) {
      if (!isClerkAPIResponseError(err)) return;

      err.errors.forEach((error: ClerkAPIError) => {
        const paramName = error.meta?.paramName as keyof IFormData | undefined;
        if (paramName && error.longMessage) {
          setError(paramName, {
            type: 'manual',
            message: error.longMessage,
          });
        } else if (paramName === undefined) {
          toast.error(error?.message);
        }
      });
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* Form fields */}
    </form>
  );
}

Validation Schemas

Store schemas in validationSchemas/ directory:

@/validationSchemas/authSchemas.ts
import { z } from 'zod';

export const signInSchema = z.object({
  identifier: z.string().email('Please enter a valid email address'),
  password: z.string().min(1, 'Password is required'),
});

export const signUpSchema = z.object({
  emailAddress: z.string().email('Please enter a valid email address'),
  password: z
    .string()
    .min(8, 'Password must be at least 8 characters')
    .regex(/[A-Z]/, 'Password must contain an uppercase letter')
    .regex(/[0-9]/, 'Password must contain a number'),
  firstName: z.string().min(1, 'First name is required'),
  lastName: z.string().min(1, 'Last name is required'),
});

Error Handling Patterns

Field-Level Errors

Field Error Display
<Input
  {...register('email')}
  errorMessage={errors.email?.message}
  isInvalid={!!errors.email}
/>

API Errors with Toast

API Error Handling
import { isClerkAPIResponseError } from '@clerk/nextjs/errors';
import type { ClerkAPIError } from '@clerk/types';

catch (err: unknown) {
  if (!isClerkAPIResponseError(err)) return;

  err.errors.forEach((error: ClerkAPIError) => {
    const paramName = error.meta?.paramName as keyof IFormData | undefined;

    if (paramName && error.longMessage) {
      setError(paramName, {
        type: 'manual',
        message: error.longMessage,
      });
    } else if (paramName === undefined) {
      toast.error(error?.message);
    }
  });
}

Form Components

StepHeader

@/components/user/StepHeader.tsx
interface IStepHeader {
  title: string;
  description: string;
}

export function StepHeader({ title, description }: IStepHeader) {
  return (
    <div className="flex flex-col gap-2">
      <h1 className="text-2xl font-bold">{title}</h1>
      <p className="text-neutral-foreground">{description}</p>
    </div>
  );
}

StepFooter

@/components/user/StepFooter.tsx
interface IStepFooter {
  title: string;
  buttonText: string;
  href: string;
}

export function StepFooter({ title, buttonText, href }: IStepFooter) {
  return (
    <p className="text-sm text-neutral-foreground">
      {title}{' '}
      <Link href={href} className="text-foreground font-medium">
        {buttonText}
      </Link>
    </p>
  );
}

Loading States

Submit Button with Loader
import { BeatLoader } from 'react-spinners';

<Button disabled={isSubmitting} type="submit">
  {isSubmitting && (
    <BeatLoader size={5} className="[&>span]:!bg-foreground" />
  )}
  {isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>

How is this guide ?

Last updated on

On this page