Let's say want to build a Form in Remix for our application, and we want to utilize the Remix Hook Form library, and we want to use Zod to validate our form. In an application with multiple forms, it can become cumbersome to import the Remix Hook Form
library in every page that requires a form. To simplify this process, we can create a custom hook library that handles the complexity of importing the library in all page.
You should know!
This post assumes the basic understanding of either Remix Hook Form or React Hook Form. Remix Hook Form is a powerful and lightweight wrapper around react-hook-form
that streamlines the process of working with forms and form data in your Remix applications.
The first approach is to create a hook component that import and use the library to create reusable hook.
app/hooks/use-form.tsx
1import { zodResolver } from "@hookform/resolvers/zod";23import {4RemixFormProvider as FormProviderRemixHookForm,5useRemixForm as useFormRemixHookForm,6useRemixFormContext as useFormContextRemixHookForm,7} from "remix-hook-form";8import { Form } from "@remix-run/react";910type FormProps = {11onSubmit?: Function;12};1314type Mode = "onBlur" | "onChange" | "onSubmit" | "onTouched" | "all";15type CriteriaMode = "firstError" | "all";16export type ErrorOption = {17message?: string;18type?: string;19types?: Record<string, string>;20};2122type HookResponse = {23formErrors: { [key: string]: {} | undefined };24resetForm: (values?: any) => void;25control: any;26setFormValue: (27name: string,28value: any,29config?: Record<string, unknown>30) => void;31getFormValues: (payload?: string | string[]) => any;32validateForm: () => Promise<boolean>;33renderForm: (children: any, formProps?: FormProps) => any;34watchForm: (payload?: string | string[]) => any;35setFormError: (fieldName: string, err: any) => void;36};3738type HookParams = {39onSubmit?: (data: Record<string, any>, e?: any) => any;40onError?: (errors: Record<string, unknown>, e?: any) => void;41callingSubmitManually?: boolean;4243defaultValues?: { [x: string]: {} | undefined } | undefined;44mode?: Mode;45reValidateMode?: Exclude<Mode, "onTouched" | "all">;46criteriaMode?: CriteriaMode;47schema?: any;48};4950/**51* Examples:52*53* setFormError("email", "This email is already used")54* setFormError("password", ["Too short", "Mix different characters"])55* setFormError("myField", { ...actual RHF error-compliant payload })56*57* @param setError58*/59const setFormErrorFactory =60(setError: {61(62name: string,63error: ErrorOption,64options?: { shouldFocus: boolean } | undefined65): void;66(67name: string,68error: ErrorOption,69options?: { shouldFocus: boolean } | undefined70): void;71(arg0: string, arg1: { type: string; message: any }): void;72}) =>73(fieldName: string, err: any) => {74if (typeof err === "string") {75setError(fieldName, { type: "manual", message: err });76} else if (Array.isArray(err)) {77setError(fieldName, { type: "manual", message: err[0] });78} else {79setError(fieldName, err);80}81};8283const defaultFormParams = {84mode: "onBlur" as Mode,85reValidateMode: "onBlur" as Exclude<Mode, "onTouched" | "all">,86criteriaMode: "all" as CriteriaMode,87};8889export default function useForm(params: HookParams = {}): HookResponse {90const { onError, schema, ...otherParams } = params;9192const useFormRemixHookFormPayload = useFormRemixHookForm({93...defaultFormParams,94...otherParams,95resolver: schema ? zodResolver(schema) : undefined,96});9798const {99handleSubmit,100reset,101formState,102setValue,103getValues,104trigger,105watch,106setError,107} = useFormRemixHookFormPayload;108109const { errors } = formState ?? {};110111const renderForm = (children: React.ReactNode, formProps = {}) => (112<FormProviderRemixHookForm {...useFormRemixHookFormPayload}>113<Form onSubmit={handleSubmit} {...formProps}>114{children}115</Form>116</FormProviderRemixHookForm>117);118119return {120formErrors: errors,121resetForm: reset,122setFormValue: setValue,123getFormValues: getValues,124validateForm: trigger,125renderForm,126watchForm: watch,127setFormError: setFormErrorFactory(setError),128...useFormRemixHookFormPayload,129};130}131132export function useFormContext() {133const { formState, watch, reset, getValues, setValue, trigger, setError } =134useFormContextRemixHookForm() ?? {};135136const { errors } = formState ?? {};137138return {139validateForm: trigger,140setFormError: setFormErrorFactory(setError),141// Aliasing142formErrors: errors,143resetForm: reset,144setFormValue: setValue,145getFormValues: getValues,146watchForm: watch,147...useFormContextRemixHookForm,148};149}150151export function FormContext(props: { children: any }) {152const { children } = props;153154const context = useFormContext();155156return children(context);157}158159export function FormProvider(props: { [x: string]: any; children: any }) {160const { children, ...otherProps } = props;161162const useFormRemixHookFormPayload = useFormRemixHookForm({163...defaultFormParams,164...otherProps,165});166167return (168<FormProviderRemixHookForm {...useFormRemixHookFormPayload}>169{children}170</FormProviderRemixHookForm>171);172}
This seems to work but there is one change I will like to make. If we want to swap out the Form component to use fetcher.Form
, there will be no way to do that. To solve that, we can accept the component as a prop. Thanks to Sergio Xalambri for this trick in his post.
app/hooks/use-form.tsx
1//app/hooks/use-form.tsx2import { zodResolver } from "@hookform/resolvers/zod";3import type { FormProps as RemixFormProps } from "@remix-run/react";45import {6RemixFormProvider as FormProviderRemixHookForm,7useRemixForm as useFormRemixHookForm,8useRemixFormContext as useFormContextRemixHookForm,9} from "remix-hook-form";10import { Form } from "@remix-run/react";11import {Component} from "react";1213type Mode = "onBlur" | "onChange" | "onSubmit" | "onTouched" | "all";14type CriteriaMode = "firstError" | "all";15export type ErrorOption = {16message?: string;17type?: string;18types?: Record<string, string>;19};2021type HookResponse = {22formErrors: { [key: string]: {} | undefined };23resetForm: (values?: any) => void;24control: any;25setFormValue: (26name: string,27value: any,28config?: Record<string, unknown>29) => void;30getFormValues: (payload?: string | string[]) => any;31validateForm: () => Promise<boolean>;32renderForm: (children: any, formProps?: RemixFormProps) => any;33watchForm: (payload?: string | string[]) => any;34setFormError: (fieldName: string, err: any) => void;35};3637type HookParams = {38onSubmit?: (data: Record<string, any>, e?: any) => any;39onError?: (errors: Record<string, unknown>, e?: any) => void;40callingSubmitManually?: boolean;4142defaultValues?: { [x: string]: {} | undefined } | undefined;43mode?: Mode;44reValidateMode?: Exclude<Mode, "onTouched" | "all">;45criteriaMode?: CriteriaMode;46schema?: any; // wil fix this and update47form?: React.ComponentType<RemixFormProps>; // addition here48};4950/**51* Examples:52*53* setFormError("email", "This email is already used")54* setFormError("password", ["Too short", "Mix different characters"])55* setFormError("myField", { ...actual RHF error-compliant payload })56*57* @param setError58*/59const setFormErrorFactory =60(setError: {61(62name: string,63error: ErrorOption,64options?: { shouldFocus: boolean } | undefined65): void;66(67name: string,68error: ErrorOption,69options?: { shouldFocus: boolean } | undefined70): void;71(arg0: string, arg1: { type: string; message: any }): void;72}) =>73(fieldName: string, err: any) => {74if (typeof err === "string") {75setError(fieldName, { type: "manual", message: err });76} else if (Array.isArray(err)) {77setError(fieldName, { type: "manual", message: err[0] });78} else {79setError(fieldName, err);80}81};8283const defaultFormParams = {84mode: "onBlur" as Mode,85reValidateMode: "onBlur" as Exclude<Mode, "onTouched" | "all">,86criteriaMode: "all" as CriteriaMode,87};8889export default function useForm(params: HookParams = {}): HookResponse {90const { onError, schema, form: Component = Form, ...otherParams } = params; // new component received9192const useFormRemixHookFormPayload = useFormRemixHookForm({93...defaultFormParams,94...otherParams,95resolver: schema ? zodResolver(schema) : undefined,96});9798const {99handleSubmit,100reset,101formState,102setValue,103getValues,104trigger,105watch,106setError,107} = useFormRemixHookFormPayload;108109const { errors } = formState ?? {};110111const renderForm = (children: React.ReactNode, formProps = {}) => (112<FormProviderRemixHookForm {...useFormRemixHookFormPayload}>113<Component onSubmit={handleSubmit} {...formProps}>114{children}115</Component>116</FormProviderRemixHookForm>117);118119return {120formErrors: errors,121resetForm: reset,122setFormValue: setValue,123getFormValues: getValues,124validateForm: trigger,125renderForm,126watchForm: watch,127setFormError: setFormErrorFactory(setError),128...useFormRemixHookFormPayload,129};130}131132export function useFormContext() {133const { formState, watch, reset, getValues, setValue, trigger, setError } =134useFormContextRemixHookForm() ?? {};135136const { errors } = formState ?? {};137138return {139validateForm: trigger,140setFormError: setFormErrorFactory(setError),141// Aliasing142formErrors: errors,143resetForm: reset,144setFormValue: setValue,145getFormValues: getValues,146watchForm: watch,147...useFormContextRemixHookForm,148};149}150151export function FormContext(props: { children: any }) {152const { children } = props;153154const context = useFormContext();155156return children(context);157}158159export function FormProvider(props: { [x: string]: any; children: any }) {160const { children, ...otherProps } = props;161162const useFormRemixHookFormPayload = useFormRemixHookForm({163...defaultFormParams,164...otherProps,165});166167return (168<FormProviderRemixHookForm {...useFormRemixHookFormPayload}>169{children}170</FormProviderRemixHookForm>171);172}
Now, we can accept fetcher.Form
from outside the hook which makes easy if one wants to swap out the Form component.
Before we use this hook in all our pages, let's build a custom input component that utilizes the Remix Hook Form useRemixFormContext
to register and create reusable input field component.
app/components/fields.tsx
1//app/components/fields.tsx2import { useRemixFormContext } from "remix-hook-form";34const InputField = ({5name,6label,7...props8}: { name: string; label: string } & JSX.IntrinsicElements["input"]) => {9const {10register,11formState: { errors },12} = useRemixFormContext();1314const error = Array.isArray(errors[name])15? // @ts-ignore16errors[name].join(", ")17: errors[name]?.message || errors[name];1819return (20<div>21<label htmlFor={name} style={{ display: "block" }}>22{label}23</label>24<input {...register(name)} {...props} />25{error && <p style={{ fontSize: "10px", color: "red" }}>{error}</p>}26</div>27);28};2930export {InputField}
Finally, we can use the input field and hook in all our page.
app/routes/projects.tsx
1//app/routes/projects.tsx2import useForm from "~/hooks/use-form";3import { getValidatedFormData } from "remix-hook-form";4import { z } from "zod";5import { zodResolver } from "@hookform/resolvers/zod";6import { type ActionArgs, json } from "@remix-run/node";7import FormInput from "~/components/fields";89//schema10const schema = z.object({11name: z.string().nonempty(),12email: z.string().email().nonempty(),13password: z.string().min(8),14image: z.preprocess((value) => {15if (Array.isArray(value)) {16// No preprocess needed if the value is already an array17return value;18} else if (value instanceof File && value.name !== "" && value.size > 0) {19// Wrap it in an array if the file is valid20return [value];21} else {22// Treat it as empty array otherwise23return [];24}25}, z.instanceof(File).array().min(1, "At least 1 file is required")),26});2728type FormData = z.infer<typeof schema>;2930//resolvers31const resolver = zodResolver(schema);3233//route action34export const action = async ({ request }: ActionArgs) => {35const {36errors,37data,38receivedValues: defaultValues,39} = await getValidatedFormData<FormData>(request, resolver);40console.log({ errors, data, defaultValues });41if (errors) {42return json({ errors, defaultValues });43}44// Do something with the data45return json(data);46};4748//client component49export default function Index() {50const { renderForm } = useForm({51schema,52defaultValues: {53name: "",54email: "",55password: "",56},57});5859return renderForm(60<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>61<h1>Welcome to Remix</h1>6263<div64style={{65display: "flex",66flexDirection: "column",67gap: "4px",68width: "200px",69}}70>71<FormInput label="Name" name="name" />72<FormInput label="Email" name="email" />73<FormInput label="Password" name="password" type="password" />74<button75type="submit"76style={{77all: "unset",78display: "flex",79alignItems: "center",80justifyContent: "center",81backgroundColor: "#116D6E",82color: "white",83width: "100%",84height: "35px",85borderRadius: "6px",86}}87>88Submit89</button>90</div>91</div>92);93}
That's it. You can now use this hook in all pages that requires a form.
I created same hook for react applications. You can find it here in my repo on Github.
Liked this article? Share it with a friend on Twitter or contact me let's start a new project. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll always be available to respond to your messages.
Have a wonderful day.
– Felix
One of the primary features of Remix is simplifying interactions with the server to get data into components.