@sudocode_

Reusable form hook with Remix Hook Form

July 11, 2023 / 9 min read

Last Updated: July 11, 2023

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

1
import { zodResolver } from "@hookform/resolvers/zod";
2
3
import {
4
RemixFormProvider as FormProviderRemixHookForm,
5
useRemixForm as useFormRemixHookForm,
6
useRemixFormContext as useFormContextRemixHookForm,
7
} from "remix-hook-form";
8
import { Form } from "@remix-run/react";
9
10
type FormProps = {
11
onSubmit?: Function;
12
};
13
14
type Mode = "onBlur" | "onChange" | "onSubmit" | "onTouched" | "all";
15
type CriteriaMode = "firstError" | "all";
16
export type ErrorOption = {
17
message?: string;
18
type?: string;
19
types?: Record<string, string>;
20
};
21
22
type HookResponse = {
23
formErrors: { [key: string]: {} | undefined };
24
resetForm: (values?: any) => void;
25
control: any;
26
setFormValue: (
27
name: string,
28
value: any,
29
config?: Record<string, unknown>
30
) => void;
31
getFormValues: (payload?: string | string[]) => any;
32
validateForm: () => Promise<boolean>;
33
renderForm: (children: any, formProps?: FormProps) => any;
34
watchForm: (payload?: string | string[]) => any;
35
setFormError: (fieldName: string, err: any) => void;
36
};
37
38
type HookParams = {
39
onSubmit?: (data: Record<string, any>, e?: any) => any;
40
onError?: (errors: Record<string, unknown>, e?: any) => void;
41
callingSubmitManually?: boolean;
42
43
defaultValues?: { [x: string]: {} | undefined } | undefined;
44
mode?: Mode;
45
reValidateMode?: Exclude<Mode, "onTouched" | "all">;
46
criteriaMode?: CriteriaMode;
47
schema?: any;
48
};
49
50
/**
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 setError
58
*/
59
const setFormErrorFactory =
60
(setError: {
61
(
62
name: string,
63
error: ErrorOption,
64
options?: { shouldFocus: boolean } | undefined
65
): void;
66
(
67
name: string,
68
error: ErrorOption,
69
options?: { shouldFocus: boolean } | undefined
70
): void;
71
(arg0: string, arg1: { type: string; message: any }): void;
72
}) =>
73
(fieldName: string, err: any) => {
74
if (typeof err === "string") {
75
setError(fieldName, { type: "manual", message: err });
76
} else if (Array.isArray(err)) {
77
setError(fieldName, { type: "manual", message: err[0] });
78
} else {
79
setError(fieldName, err);
80
}
81
};
82
83
const defaultFormParams = {
84
mode: "onBlur" as Mode,
85
reValidateMode: "onBlur" as Exclude<Mode, "onTouched" | "all">,
86
criteriaMode: "all" as CriteriaMode,
87
};
88
89
export default function useForm(params: HookParams = {}): HookResponse {
90
const { onError, schema, ...otherParams } = params;
91
92
const useFormRemixHookFormPayload = useFormRemixHookForm({
93
...defaultFormParams,
94
...otherParams,
95
resolver: schema ? zodResolver(schema) : undefined,
96
});
97
98
const {
99
handleSubmit,
100
reset,
101
formState,
102
setValue,
103
getValues,
104
trigger,
105
watch,
106
setError,
107
} = useFormRemixHookFormPayload;
108
109
const { errors } = formState ?? {};
110
111
const renderForm = (children: React.ReactNode, formProps = {}) => (
112
<FormProviderRemixHookForm {...useFormRemixHookFormPayload}>
113
<Form onSubmit={handleSubmit} {...formProps}>
114
{children}
115
</Form>
116
</FormProviderRemixHookForm>
117
);
118
119
return {
120
formErrors: errors,
121
resetForm: reset,
122
setFormValue: setValue,
123
getFormValues: getValues,
124
validateForm: trigger,
125
renderForm,
126
watchForm: watch,
127
setFormError: setFormErrorFactory(setError),
128
...useFormRemixHookFormPayload,
129
};
130
}
131
132
export function useFormContext() {
133
const { formState, watch, reset, getValues, setValue, trigger, setError } =
134
useFormContextRemixHookForm() ?? {};
135
136
const { errors } = formState ?? {};
137
138
return {
139
validateForm: trigger,
140
setFormError: setFormErrorFactory(setError),
141
// Aliasing
142
formErrors: errors,
143
resetForm: reset,
144
setFormValue: setValue,
145
getFormValues: getValues,
146
watchForm: watch,
147
...useFormContextRemixHookForm,
148
};
149
}
150
151
export function FormContext(props: { children: any }) {
152
const { children } = props;
153
154
const context = useFormContext();
155
156
return children(context);
157
}
158
159
export function FormProvider(props: { [x: string]: any; children: any }) {
160
const { children, ...otherProps } = props;
161
162
const useFormRemixHookFormPayload = useFormRemixHookForm({
163
...defaultFormParams,
164
...otherProps,
165
});
166
167
return (
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.tsx
2
import { zodResolver } from "@hookform/resolvers/zod";
3
import type { FormProps as RemixFormProps } from "@remix-run/react";
4
5
import {
6
RemixFormProvider as FormProviderRemixHookForm,
7
useRemixForm as useFormRemixHookForm,
8
useRemixFormContext as useFormContextRemixHookForm,
9
} from "remix-hook-form";
10
import { Form } from "@remix-run/react";
11
import {Component} from "react";
12
13
type Mode = "onBlur" | "onChange" | "onSubmit" | "onTouched" | "all";
14
type CriteriaMode = "firstError" | "all";
15
export type ErrorOption = {
16
message?: string;
17
type?: string;
18
types?: Record<string, string>;
19
};
20
21
type HookResponse = {
22
formErrors: { [key: string]: {} | undefined };
23
resetForm: (values?: any) => void;
24
control: any;
25
setFormValue: (
26
name: string,
27
value: any,
28
config?: Record<string, unknown>
29
) => void;
30
getFormValues: (payload?: string | string[]) => any;
31
validateForm: () => Promise<boolean>;
32
renderForm: (children: any, formProps?: RemixFormProps) => any;
33
watchForm: (payload?: string | string[]) => any;
34
setFormError: (fieldName: string, err: any) => void;
35
};
36
37
type HookParams = {
38
onSubmit?: (data: Record<string, any>, e?: any) => any;
39
onError?: (errors: Record<string, unknown>, e?: any) => void;
40
callingSubmitManually?: boolean;
41
42
defaultValues?: { [x: string]: {} | undefined } | undefined;
43
mode?: Mode;
44
reValidateMode?: Exclude<Mode, "onTouched" | "all">;
45
criteriaMode?: CriteriaMode;
46
schema?: any; // wil fix this and update
47
form?: React.ComponentType<RemixFormProps>; // addition here
48
};
49
50
/**
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 setError
58
*/
59
const setFormErrorFactory =
60
(setError: {
61
(
62
name: string,
63
error: ErrorOption,
64
options?: { shouldFocus: boolean } | undefined
65
): void;
66
(
67
name: string,
68
error: ErrorOption,
69
options?: { shouldFocus: boolean } | undefined
70
): void;
71
(arg0: string, arg1: { type: string; message: any }): void;
72
}) =>
73
(fieldName: string, err: any) => {
74
if (typeof err === "string") {
75
setError(fieldName, { type: "manual", message: err });
76
} else if (Array.isArray(err)) {
77
setError(fieldName, { type: "manual", message: err[0] });
78
} else {
79
setError(fieldName, err);
80
}
81
};
82
83
const defaultFormParams = {
84
mode: "onBlur" as Mode,
85
reValidateMode: "onBlur" as Exclude<Mode, "onTouched" | "all">,
86
criteriaMode: "all" as CriteriaMode,
87
};
88
89
export default function useForm(params: HookParams = {}): HookResponse {
90
const { onError, schema, form: Component = Form, ...otherParams } = params; // new component received
91
92
const useFormRemixHookFormPayload = useFormRemixHookForm({
93
...defaultFormParams,
94
...otherParams,
95
resolver: schema ? zodResolver(schema) : undefined,
96
});
97
98
const {
99
handleSubmit,
100
reset,
101
formState,
102
setValue,
103
getValues,
104
trigger,
105
watch,
106
setError,
107
} = useFormRemixHookFormPayload;
108
109
const { errors } = formState ?? {};
110
111
const renderForm = (children: React.ReactNode, formProps = {}) => (
112
<FormProviderRemixHookForm {...useFormRemixHookFormPayload}>
113
<Component onSubmit={handleSubmit} {...formProps}>
114
{children}
115
</Component>
116
</FormProviderRemixHookForm>
117
);
118
119
return {
120
formErrors: errors,
121
resetForm: reset,
122
setFormValue: setValue,
123
getFormValues: getValues,
124
validateForm: trigger,
125
renderForm,
126
watchForm: watch,
127
setFormError: setFormErrorFactory(setError),
128
...useFormRemixHookFormPayload,
129
};
130
}
131
132
export function useFormContext() {
133
const { formState, watch, reset, getValues, setValue, trigger, setError } =
134
useFormContextRemixHookForm() ?? {};
135
136
const { errors } = formState ?? {};
137
138
return {
139
validateForm: trigger,
140
setFormError: setFormErrorFactory(setError),
141
// Aliasing
142
formErrors: errors,
143
resetForm: reset,
144
setFormValue: setValue,
145
getFormValues: getValues,
146
watchForm: watch,
147
...useFormContextRemixHookForm,
148
};
149
}
150
151
export function FormContext(props: { children: any }) {
152
const { children } = props;
153
154
const context = useFormContext();
155
156
return children(context);
157
}
158
159
export function FormProvider(props: { [x: string]: any; children: any }) {
160
const { children, ...otherProps } = props;
161
162
const useFormRemixHookFormPayload = useFormRemixHookForm({
163
...defaultFormParams,
164
...otherProps,
165
});
166
167
return (
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.tsx
2
import { useRemixFormContext } from "remix-hook-form";
3
4
const InputField = ({
5
name,
6
label,
7
...props
8
}: { name: string; label: string } & JSX.IntrinsicElements["input"]) => {
9
const {
10
register,
11
formState: { errors },
12
} = useRemixFormContext();
13
14
const error = Array.isArray(errors[name])
15
? // @ts-ignore
16
errors[name].join(", ")
17
: errors[name]?.message || errors[name];
18
19
return (
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
};
29
30
export {InputField}

Finally, we can use the input field and hook in all our page.

app/routes/projects.tsx

1
//app/routes/projects.tsx
2
import useForm from "~/hooks/use-form";
3
import { getValidatedFormData } from "remix-hook-form";
4
import { z } from "zod";
5
import { zodResolver } from "@hookform/resolvers/zod";
6
import { type ActionArgs, json } from "@remix-run/node";
7
import FormInput from "~/components/fields";
8
9
//schema
10
const schema = z.object({
11
name: z.string().nonempty(),
12
email: z.string().email().nonempty(),
13
password: z.string().min(8),
14
image: z.preprocess((value) => {
15
if (Array.isArray(value)) {
16
// No preprocess needed if the value is already an array
17
return value;
18
} else if (value instanceof File && value.name !== "" && value.size > 0) {
19
// Wrap it in an array if the file is valid
20
return [value];
21
} else {
22
// Treat it as empty array otherwise
23
return [];
24
}
25
}, z.instanceof(File).array().min(1, "At least 1 file is required")),
26
});
27
28
type FormData = z.infer<typeof schema>;
29
30
//resolvers
31
const resolver = zodResolver(schema);
32
33
//route action
34
export const action = async ({ request }: ActionArgs) => {
35
const {
36
errors,
37
data,
38
receivedValues: defaultValues,
39
} = await getValidatedFormData<FormData>(request, resolver);
40
console.log({ errors, data, defaultValues });
41
if (errors) {
42
return json({ errors, defaultValues });
43
}
44
// Do something with the data
45
return json(data);
46
};
47
48
//client component
49
export default function Index() {
50
const { renderForm } = useForm({
51
schema,
52
defaultValues: {
53
name: "",
54
email: "",
55
password: "",
56
},
57
});
58
59
return renderForm(
60
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
61
<h1>Welcome to Remix</h1>
62
63
<div
64
style={{
65
display: "flex",
66
flexDirection: "column",
67
gap: "4px",
68
width: "200px",
69
}}
70
>
71
<FormInput label="Name" name="name" />
72
<FormInput label="Email" name="email" />
73
<FormInput label="Password" name="password" type="password" />
74
<button
75
type="submit"
76
style={{
77
all: "unset",
78
display: "flex",
79
alignItems: "center",
80
justifyContent: "center",
81
backgroundColor: "#116D6E",
82
color: "white",
83
width: "100%",
84
height: "35px",
85
borderRadius: "6px",
86
}}
87
>
88
Submit
89
</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.