Create advanced forms with maximum type-safety and client/server validation automatically by simply defining a schema.
This package is a handy wrapper for React Hook Form and Remix Hook Form that lets you create advanced forms safely by only defining the desired data schema. We describe what fields we need, what type of data we expect, and where to output it... and that's all! The package takes care of rendering the form properly, validating data (both client and server-side!), showing errors, handling files, and makes sure that when the data reaches the server-side, we can safely use it.
There is a lot out of the box, but you can customize it quite heavily. We can add our own custom field types, additional validation for both client and server-side, custom file upload handlers or even take over the rendering process, so we can decide where to put each form field or make them render conditionally. It allows us to use it even for very sophisticated forms.
It's built in easy-integration in mind, so it works great with any UI and internalization libraries. Do you use NextUI, ShadCN or XYZUI? No problem! Do you have any translations type that is union of possible lang keys? No problem. Give it to the package and it will make sure that the rendered that all labels, descriptions, or errors are indeed translated. It as simple as that!
- Automatic rendering of the form based on the given schema and UI components
- Out of the box client/server validation (based on Zod + Remix-hook-form)
- Maximum type-safety – always know what to expect
- Ready to use zod helpers for most used form field types (text, number, file, date, etc.)
- Support for intl libraries
- Easy customization
You may think that this package is a bit like Remix-forms and indeed it is. Both packages share a lot of similarities. After all, they aim to provide the same goal – automatic form rendering and easy (type-safety) client-server validation. However this is my personal take on that matter, based on my own experiences. What mainly differs is the implementation.
Firstly, Remix-forms treats schema as a simple Zod object, whereas this package uses it as the main place for whole form fields configuration. We can easily define not only validation itself but also decide on more UI-focused stuff (like label, description, field options, required flag) and even condition some fields based on the value of others. All of these can be set just right up from the start in the schema object, whereas Remix-forms offers this only at the component level.
Secondly, Remix-schema-form offers easier UI components integration. While Remix-forms offers using custom UI components for the fields, we have to directly use them in the component render function or standard HTML input will be used. It heavily impacts the auto-generated feature, which cannot be simply used in that scenario without creating a custom wrapper. In our package, we aim to make the user decide upfront about what component should be used for each field type. It is more requiring, but after the basic setup, the user can create forms however they like, and the package will use proper components automatically.
At last, both packages try to provide the user with some customization capabilities. However, Remix-forms offers a little more in this matter. Remix-schema-form provides an easier plug-and-play experience, but it makes it also a little harder to extend it if we want we want go really far beyond what the author prepared.
So what to choose? It depends. If you want to start quickly and have a lot of options out of the box, this package can be a really good choice. If you plan to extend its core functionalities a lot, Remix-forms might be a better fit. And what's better for production grade apps? If you want something production-ready, Remix-forms can be (for now!) a better choice. After all it's already in use for a while and we can see it as more battle-tested.
You can install it using any package manager.
npm install --save-dev remix-schema-form
yarn add --save-dev remix-schema-form
pnpm install --save-dev remix-schema-form
In the most basic scenario, we can use the package straight up in the route file where we set up our form.
import type { ActionFunctionArgs } from "@remix-run/node";
import type { BaseFormFieldsSchema } from "remix-schema-form";
import { ValidatedFormBase, zCheckbox, zFile, zText } from "remix-schema-form";
import "remix-schema-form/dist/index.css";
import { getFormDataBase } from "remix-schema-form/server"
export const schema = {
firstName: {
type: "text",
label: "First name",
validation: zText({ min: 2, max: 20 })
},
lastName: {
type: "password",
label: "Last name",
validation: zText({ min: 2, max: 20 })
},
avatar: {
type: "file",
label: "First name",
validation: zFile({
maxSize: 50 * 1024 * 1024,
accept: ["image/png", "image/jpg", "image/jpeg"]
})
},
accept: {
type: "checkbox",
label: "Terms of use",
description: "You have to accept our terms of use",
validation: zCheckbox({ required: true })
}
} satisfies BaseFormFieldsSchema;
export const action = async ({ request }: ActionFunctionArgs) => {
const { data, error } = await getFormDataBase(request, schema);
if (error)
return error;
else
console.log(data); // safe and typed data!
return { success: true };
}
export default function Index() {
return (
<ValidatedFormBase
mode="onChange"
schema={schema}
successMessage="Success!"
submitActionName="Submit"
/>
);
}
We start by defining the schema. We decide what fields (and what type) we need and how the app should validate it. At default, we can use only basic fields (link TODO), but we can easily create custom ones. Type is very important. Firstly, it determines what React component to use when rendering the specific field. Secondly, it determines additional options that can be provided to it. After all, there will be other possible options for text field type and different dates, right? Regarding validation, we've used some built-in helpers here (zod helpers list TODO), but we could create zod rules ourselves as well.
In the component section, we do only one thing – render ValidatedFormBase
. It's role is to render our form based on the schema object we've provided. There are only two props that are required. schema
which tells the component what fields to render and submitActionName
which decides about the submit button content. And... that's all! If we don't provide UI components and error messages, the package will use default ones. They are very simple and we would probably want to change them for custom ones, but... it will work!
Optionally, if we use default components, we can also import some basic styles import "remix-schema-form/dist/index.css";
. It's nothing fancy, but at least there will be some styling.
In our example we also included mode
and successMessage
prop. mode
decides when to validate the form (defaults to onSubmit
), successMessage
will be shown when form submit is a success (action returned { success: true }
).
Here you can check all available props – link TODO.
And that's all! The package will now render our form automatically and make sure to follow our schema and add proper validation.
But that's for the client-side. What about the server-side? After the submit, data will be validated again (thanks to the getFormDataBase
func). You can be sure, that if error
is null, the data is present, safe, and exactly in the shape we wanted.
And what is the best here, TS is carefully watching us all the time! At schema creation time, it makes sure we use a field type that exists and properly describe the expected data (e.g. validation and label are required). At the server-side it will properly hint us about the data types. For instance, if there is no error and accept
is required, TS will inform us that it's surely true
. No boolean, just true
. As for File
... well, it's not required, so TS will hint us about File
or undefined
type. Cool, right?
And if there was an error and we return it, client-side will pick it up and render it automatically!
[!NOTE]
You can wonder, how it is possible that file upload already works. The answer is simple – because the package has a default file upload handler. It's very basic and simply uploads files to "/public/uploads" folder giving them unique names. You would probably want to use something more advanced, but... it works out of the box. If you need something quick and simple or it in the prototype app, these all default stuff make it very handy.
Defaults are nice, but of course, they are rarely enough. We need custom special fields, translated errors, additional validation, custom components, and much much more customization.
Let's start with custom components and get rid of these ugly defaults.
The package lets us define component for each supported field type and two special ones – for button and alerts. We can do this by providing components
object prop to ValidatedFormBase
.
Thankfully there is no much work to do and we can easily integrate UI components from any chosen library.
Form field components are always rendered with at least props below:
– label: string
(defaults to empty string)
– description: string
(defaults to empty string)
– error: string
or null
– required: boolean
– disabled: boolean
– control
– fieldName
– register
label
, description
, required
or disabled
are simply picked up from the schema and can be used by component to properly describe and style the field.
error
is, of course, error message than will be present if validation fails.
The most important ones are fieldName
and register
field. As our package relies on remix-hook-form our form fields have to be properly registered with register
func. And this the only requirement for our components – they have to register our field. Using the rest of props is encouraged (we should at least render error message), but technically not necessary.
control
is completely optional, but it gives you remix-hook-form data that you can use if you wish.
Often we will also have one more prop to use – options
. Some of the field types allows for additional options, like "placeholder", "type", "minDate" and so on. If this info is set in schema, options
prop will have this information stored. E.g. if text
type field allows for "placeholder" in schema, here we will be able to access it.
Ok. Less talking, more coding.
Let's create new file, called formComponents.tsx
. We will define our components here.
Inside create an object and type it as ValidatedFormBaseComponents
. This type will make sure we've choosen component for every field type and button/alert ones. It will also hint us, what can we expect when it comes to props. It will be very handy especially with options
prop that can be different for various field types.
// formComponents.tsx
import type { ValidatedFormComponents } from "remix-schema-form";
export const formComponents: ValidatedFormComponents = {}
In the easiest scenario we can define very simple components.
Consider example TextField
component that we could create to serve "text" type fields.
import { OptionsForBasicType } from "remix-schema-form";
import { DefaultFormFieldComponentBaseProps } from "remix-schema-form/src/utils/types";
export const TextField = ({
label,
description,
options,
error,
fieldName,
register,
required,
disabled,
}: DefaultFormFieldComponentBaseProps & {
options?: OptionsForBasicType<"text">
}) => {
return (
<div>
<label>
<span>
{label}
{required && <span style={{ color: "red" }}>*</span>}
</span>
<input
type={options?.role || "text"}
placeholder={options?.placeholder || ""}
style={error ? { border: "1px solid red" } : {}}
disabled={disabled}
required={required}
{...register(fieldName)}
/>
{description && <div>{description}</div>}
{error && <div style={{ color: "red" }}>{error}</div>}
</label>
</div>
);
};
Notice what props we expect. We use all the standard ones (label
, description
etc.) and one additional – options
. Not every field has options prop, but text
does. It allows for "role" ("text" | "email" | "search" | "tel" | "url") and string placeholder. You can read what basic fields support what options here (link TODO). We used DefaultFormFieldComponentBaseProps
to get hints on default component props and OptionsForBasicType
for text type
to get also the hints on possible options for this specific field type.
The render part is quite self-explainatory. {...register(fieldName)}
is the only "magic" part, but if you use react-hook-from at daily basis, it's nothing new. It makes the library aware of the field state.
When it's ready, we can simply use it in out components object.
export const formComponents: ValidatedFormComponents = {
text: TextField,
...
}
It is recommended way, especially if we have a lot of custom fields, but we could also define the components directly here:
export const formComponents: ValidatedFormComponents = {
text: ({
label,
description,
options,
error,
fieldName,
register,
required,
disabled
}) => (
<div>
<label>
<span>{label}</span>
<input
type={options?.role ?? "text"}
placeholder={options?.placeholder ?? ""}
style={error ? { border: "1px solid red" } : {}}
disabled={disabled}
required={required}
{...register(fieldName)}
/>
{description && <div>{description}</div>}
{error && <div style={{ color: "red" }}>{error}</div>}
</label>
</div>
)
...
}
This way, TS would hint us the props types automatically without needing to import DefaultFormFieldComponentBaseProps
or OptionsForBasicType
Next components for the rest of the basic types would look really similar. The only thing that differs is an options
prop and its structure.
Don't forget about two special components: button and alert! We have to take care of them.
Button component will receive: – type: "button" | "submit" | "reset"; – isSubmitting?: boolean; – children: React.ReactNode;
children
for now is basically "submitAction" prop value that we provide to ValidatedFormBase
. type
is currently always the "submit" one (it can be expanded in the future). isSubmitting
informs us if form is currently in submitting state and can be used for displaying some kind of spinner.
Alert component will receive:
– variant: "error" | "success"
– items: string[]
(if error) | undefined
– children: React.ReactNode (if success)
items
is present only at error
variant and is simply array of errors messages.
Example:
submitBtn: (args) => (
<button type={args.type}>
{ args.isSubmitting ? "Submitting..." : args.children}
</button>
),
message: ({ variant, items, children }) => {
if (variant === "success")
return (
<div style={{ margin: "10px auto", maxWidth: "200px", padding: "10px", border: "2px solid green"}}>
<h2>Success!</h2>
{children}
</div>
)
else
return (
<div style={{ margin: "10px auto", maxWidth: "200px", padding: "10px", border: "2px solid red"}}>
<h2>Error!</h2>
{items.join(", ")}
</div>
)
}
After we define our components object, the only thing that left it to provide it to ValidatedFormBase
as a prop.
<ValidatedFormBase
mode="onChange"
components={formComponents}
schema={schema}
successMessage="Success!"
submitActionName="Submit"
/>
...and that's all! :)
As we said before, you use, of course, some kind of UI library and choose it's components instead.
Example? For NextUI library text input could look like this:
import { Input } from "@nextui-org/react";
import { OptionsForBasicType } from "remix-schema-form";
import { DefaultFormFieldComponentBaseProps } from "remix-schema-form/src/utils/types";
import { FormFieldComponentBaseProps } from "~/modules/core/forms/utils/types";
export const TextField = ({
label,
description,
options,
error,
fieldName,
register,
required,
disabled,
}: DefaultFormFieldComponentBaseProps & {
options?: OptionsForBasicType<"text">
}) => {
return (
<Input
className="max-w-xs"
label={label}
labelPlacement="outside"
description={description}
type={options?.role ?? "text"}
placeholder={options?.placeholder ?? ""}
isInvalid={!!error}
isDisabled={disabled}
errorMessage={error}
isRequired={required}
{...register(fieldName)}
/>
)
}
Remix-schema forms comes up with some pre-built field types.
There are: – text – password – textarea – select – switch – checkbox – number – file – files – date
That's quite a few, but often not enough. Therefore the package allows for adding our own.
How? Let's make an example field type together. Maybe for a color input.
Start by creating some kind of config file.
//formsConfig.ts
import type { FormFieldsSchemas } from "remix-schema-form"
// Register custom field types
export type CustomSupportedFieldType = "color"
// Prepare schemas for custom field types
export type CustomFieldsSchemas = FormFieldsSchemas<CustomSupportedFieldType, string, {
color: {
options?: {
picker?: boolean
}
},
}>
We need at least two types here: one that defines our custom field types names and second that decide what options do this fields support (if any).
In example above we define custom type "color" and decide that it supports boolean option picker. Let's say it will decide if we can use color picker or we have to type color name manually.
Of course, we can just leave color
empty:
color: {},
This way, we would not support any options for these fields, but let's say we want to support it. What then?
Let's go back to our components file.
First we have to change our object type. For now it only hints us the basic components. In order to also support our custom ones we have to provide the ValidatedFormComponent
type both custom field types names and schemas.
export const formComponents: ValidatedFormComponents<Record<CustomSupportedFieldType, never>> = {
Thanks to that TS will be able to make sure that we determine components for custom fields as well and hints us about props they are receiving.
For instance, we can define our color form field component as follows:
color: (args) => args.options?.picker
? <input type="color" />
: <input type="text" />,
Ts will hints us that options are well... optional here, but what's more important, that picker is an boolean. How does it know this? Because it has access to structure we've defined before:
export type CustomFieldsSchemas = FormFieldsSchemas<CustomSupportedFieldType, string, {
color: {
options?: {
picker?: boolean
}
},
}>
And that's it! Now when we can use our type in schema and the package will properly render it as <input type="color" />
. But wait! There is one more thing. For now we were using BaseFormFieldsSchema
as schema type. It doesn't know our custom fields. We have to change that.
Thankfully it will be really easy. BaseFormFieldsSchema
is a generic type that takes two args. First informs about possible language keys and the second one can be our custom FormFieldsSchema
.
export const schema = {
color: {
type: "color",
label: "Color",
validation: z.string(),
options: {
picker: true
}
},
} satisfies BaseFormFieldsSchema<string, CustomFieldsSchemas>
We've used string as lang key union as we don't care about internalization in this example. The second arg is simply our custom fields schema, we've created before. Thanks to that TS knows that we can use field type "color" and it has options with picker boolean property.
And now that's really it! :) It works... It wasn't so hard, was it?
[!NOTE]
Notice, that we haven't used any built-in helpers for validation. Well... It's because the package had it prepared only for basic fields and color is not of them. But that's good, because now you have a real example that we can use old school custom zod validation as well ;)
In modern worth we rarely focus only on one market. We try to diversify for different countries and that means we need to support different languages.
Remix-schema-form is built in internalization in mind and let's you quickly integrate any intl package you use.
When it comes to translations and forms, there are three aspect we have to areas we have to cover.
First one are errors. The package at default returns only simple keys while validating. It should be a task for intl library to get it and transform into proper message in currently set language.
Therefore the package needs to have a translate func provided that it could use for translations. From simple keys like "invalidType" to real translation "Invalid type".
The second one are field labels, button contents are messages. For instance, when we set the labels in the schema and we can store only keys there. It's not part of component function or loader/action, so we don't have access to translation plugins after all. So it has to the BaseValidatedForm
task to translate and render them. Therefore again – we need to provide translation function to the BaseValidatedForm
component.
When it comes to messages or button label, we could technically translate them upfront as we provide their values as props anyway, but... who likes mixed patterns? Therefore for them, BaseValidatedForm
also expects keys and take the translation process as it's own responsibility.
When we do not provide translation func, the package assume that there is no internalization and default translation func is used. It translated error keys to simple english messages (it works only zod helpers errors) and leave your own errors, labels and messages untranslated. So if you don't use internalization, you can safely ignore that there is some kind of default translation func beneath. You can just write zod errors or labels in plain english, like it's not even there.
Some intl libs give you an union type that has all the available lang keys. That's great, because thanks to that you know if use the key that really exists. If you have such a type, you can provide it to the package and TS will give you hints while creating the schema or using the props in components.
It sounds nice, but how can we do this?
First of all, we need to provide BaseValidatedForm
that follows expected structure – (key: string, values?: Record<string, any>) => string
The package doesn't care about inner implementation. It provides to the func key (error key, label key etc.), values (e.g. "max" for "tooLong" error) and expects that will receive translated string. How this function will prepare this string is up to you.
For instance if you remix-i18n, this func could look like this:
const translateFunc = (
key: string,
values?: Record<string, any>
) => {
return t(key, values)
}
When the function is ready, you have to provide it to BaseValidatedForm
as prop and... that's all!
<ValidatedFormBase
...
translateFunc={translateFunc}
/>
Now it will be used by the package to translate all the labels, errors and messages.
For instance, if one of our fields will have label "title", the final name in the form will be equal to result of t("title")
.
[!IMPORTANT]
Take a note, that when providing custom translate func, the default one is nto longer in use. Therefore, you have to cover all translations for basic errors that normally was handler by it.
Here are the lang keys that your intl library should properly translate:
{
required: "Required",
invalidFormData: "Something went wrong...",
invalid: "Invalid value",
tooLong: "Too long (max {{max}})",
tooShort: "Too short (min {{min}})",
tooBig: "Too big (max {{max}})",
tooSmall: "Too small (min {{min}})",
fileIsTooBig: "File is too big (max {{max}} MB)",
unsupportedFormat: "Invalid format (accepted: {{formats}})",
invalidDate: "Invalid date",
dateTooEarly: "Date is too early (min: {{min}})",
dateTooLate: "Date is too late (max: {{max}})",
dateRangeInvalid: "Out of range",
dateRangeNotEnd: "End date not found",
nameAlreadyExists: "Already exists",
passwordsDontMatch: "Passwords don't match",
invalidEmail: "Invalid e-mail address",
invalidPhone: "Invalid phone number",
tooManyElements: "Too many elements (max: {{max}})",
tooFewElements: "Too few elements (min: {{min}})",
}
Ok, translations should work just fine now. But what about hinting? For now, we can still write anything as the label or submitAction
and TS doesn't complain. That's bad.
When it comes to the schema, we can easily fix that. We need to make only two changes.
First go to your CustomFieldsSchemas
and swap string
for your lang key union.
For instance:
// Prepare schemas for custom field types
export type CustomFieldsSchemas = FormFieldsSchemas<CustomSupportedFieldType, "foo" | "bar", {
color: {
options?: {
picker?: boolean
}
},
}>
Then change your schema type. BaseFormFieldsSchema
is a generic type where first arg should be lang key union.
export const schema = {
color: {
type: "color",
label: "Color",
validation: z.string(),
options: {
picker: true
}
},
} satisfies BaseFormFieldsSchema<"foo" | "bar", CustomFieldsSchemas>
And that's all. Now TS will now that label has to be one of our lang keys.
Of course, we could provide the union type instead of writing the union directly and that would be more desired way.
Ok, that's enough for the schema, but what about successMessage
and submitActionName
. It seems that BaseValidatedForm
still supports any string. That's right and there isn't ready way to just change that. But... We can easily create a wrapper for it that adds proper requirement. After all, we already set so many repeatable props for BaseValidatedForm
that it would be worth to create some kind of wrapper anyway.
Just look at this:
const translateFunc = (
key: string,
values?: Record<string, any>
) => {
...
}
return (
<ValidatedFormBase
mode="onChange"
translateFunc={translateFunc}
components={formFieldComponents}
schema={schema}
successMessage="Success!"
submitActionName="Submit"
/>
);
Do you want to repeat it over and over? Not really.
And if we are going to create ValidatedFormBase
anyway, we can easily use that fact to make successMessage
and submitActionName
require proper lang key as well.
For example:
export const ValidatedForm = <Schema extends AnyFormFieldsSchema>({
schema,
submitActionName,
successMessage,
}: {
schema: Schema,
successMessage: MyLangKeyUnion,
submitActionName: MyLangKeyUnion
}) => {
const translateFunc = (
key: string,
values?: Record<string, any>
) => {
...
}
return (
<ValidatedFormBase
mode="onChange"
translateFunc={translateFunc}
components={formComponents}
schema={schema}
successMessage={successMessage}
submitActionName={submitActionName}
/>
);
}
We have two pros here. Firstly, we can use our ValidatedForm
much easier:
<ValidatedForm
schema={schema}
successMessage={"success"}
submitActionName={"submit"}
/>
Secondly, TS will make sure that both successMessage
and submitActionName
are proper lang keys.
There is still one thing inside that we haven't covered yet and... it's quite important. When we defined lang key union type in BaseFormFieldsSchema
, TS has automatically started to make sure our label,description and even options are using existing lang key. But we haven't talked about options enough.
When it comes to description
and label
, these all should be always a lang keys. No matter if is basic field or custom one – TS is sure about that. But what about options? When options is string, should be it considered as lang key? Or not always? For basic fields, situation is already covered by the package. But what about custom fields? Well... TS never guesses. Therefore we have to inform it explicitly what options should be considered lang keys.
Thankfully, it's not that hard.
Firstly, we have to create the object that defines our needs using
OptionsTranslationConfigCustom
type.
For instance:
import type { FormFieldsSchemas, OptionsTranslationConfigCustom } from "remix-schema-form"
// Register custom field types
export type CustomSupportedFieldType = "color"
// Prepare schemas for custom field types
export type CustomFieldsSchemas = FormFieldsSchemas<CustomSupportedFieldType, "foo" | "bar", {
color: {
options?: {
picker?: boolean
someKindOfString: string
}
},
}>
// Custom fields options translations config
export const customFieldsTranslations: OptionsTranslationConfigCustom<
CustomSupportedFieldType,
CustomFieldsSchemas
> = {
color: {
someKindOfString: true
}
}
OptionsTranslationConfigCustom
makes sure we try to define options translations only for existing types and properties that are in fact strings. true
on property key means simply "this options should be translated" by ValidatedFormBase
translate function.
It covers even more advanced options structures (more on this here TODO).
Then we only have to provide this object customFieldsTranslations
to ValidatedFormBase
as customFieldsTranslationConfig
and... that's all. It's simple as that.
We often use forms in edit pages. Therefore the package allows for setting default data for the fields. We can do that easily by simply providing defaultValues
object prop to ValidatedFormBase
where keys are field names and values are... well – default values.
Sometimes there is need to add some additional validation that takes into account more than one field. Then zod validation
property in the schema is not enough. After all it gives us the access only to the one specific field values.
Thankfully, we can easily add additional validation that has access to more than just one field.
All we have to do, is to create an object that defines it. We can save it forms config file.
// Custom additional validators for custom field types
// they run after standard validation passes
export const customAdditionalValidators: FieldAdditionalValidators<CustomSupportedFieldType, MyLangKey, CustomFieldsSchemas> = {
color: ({ data, fieldName, options }) => {
if (somethingIsWrong)
return {
message: `error|value=${someValue}`,
path: [fieldName]
}
return null;
}
}
First thing is to use proper type.
fieldAdditionalValidators<CustomSupportedFieldType, MyLangKey, CustomFieldsSchemas>
It takes our custom fields types, schemas and our lang key union, to make sure we can add additional validation only to fields that in fact exist and our validation func is properly typed.
data
gives us access to all the fields. That's something we didn't have in single field validation. fieldName
is simply fieldName. We have also options
which gives us field options. There are also value
and schema
which you can easily figure out :)
Validation func can have any code inside, but it is important what it returns. It can return null
which simply means "everything is okay" or error object. In this second scenario we should provide message (it should be proper lang key with any potential values gathered after |
and split by &
) and path
. The path
should be an array with field names we want to mark as "with error".
When the object is ready, we simply has to provide it as customAdditionalValidators
prop to ValidatedFormBase
.
[!NOTE]
Additional validators run only when basic single field validators pass. It means that user will see potential errors generated by them only if he fixes basic ones first.
It is important to add custom validators on the server-side as well to provide equally strong validation on both ends. We can do this by providing the same validators object to getFormDataBase
.
const { data, error } = await getFormDataBase(request, schema, {
customAdditionalValidators
});
Sometimes we want to validate something that doesn't make much sense client-side, but is crucial on the back-end.
It's basically the same idea as custom additional validators, only with this change that it is provided only to getFormDataBase
and not to ValidatedFormBase
.
We create validators object with proper typing.
// Custom server validators for custom field types
// they run only server-side after standard validation passes
export const customServerValidators: FieldServerValidators<CustomSupportedFieldType, MyLangKey, CustomFieldsSchemas> = {
color: ({ data, fieldName, options }) => {
if (somethingIsWrong)
return {
message: `error|value=${someValue}`,
path: [fieldName]
}
return null;
}
}
And then provide it to getFormDataBase
:
const { data, error } = await getFormDataBase(request, schema, {
customAdditionalValidators,
customServerValidators
});
That is basically it.
Sometimes even after getFormDataBase
we tend to do some additional stuff and when it fails, we want to return the error. In that scenario form is technically ok, so we don't got any error from getFormDataBase
, but we want to return one. And often we want to assign it to specific fields.
Well, it is possible!
getFormDataBase
in case of success, returns createCustomError
func.
export const action = async ({ request }: ActionFunctionArgs) => {
const { data, error, createCustomError } = await getFormDataBase(request, schema);
if (error)
return error
else
console.log(data);
if (somethingIsWrong)
return createCustomError({
color: "message"
})
return { success: true };
}
It's very easy to use. We have to provide the object where key
is fieldName we want assign the error to and the values is error message. We can also return root error by using "root" key. Then we will receive on the client-side global error with given message instead of field error.
if (somethingIsWrong)
return createCustomError({
root: "message"
})
Sometimes we want to build forms with structure that reacts to user action. So some fields are only shown when other specific fields have specific values. For instance, show "seat chooser" field, only if client chooses more than 0 tickets.
You can wonder if it's possible when using autogenerated forms, but... it is! And it is quite simply. The only thing we have to do, is to inform the form how we want to condition them.
We can do that by defining simple object of type SchemaConditionals<typeof schema>
.
export const schemaConditionals = {
seatChooser: (data) => data.tickets > 0,
} satisfies SchemaConditionals<typeof schema>
We can define conditional func for each of the form fields. This func has access to current form data and it returns true -> the field will be visible and send to the server. If not -> it will be omitted.
So in our example above, we dimply define that seatChooser
field should be visible only of current tickets amount is more than zero. Easy, right?
[!TIP] We can also add special
__watch
key to the object, where we explicitly tell the form which form fields are used to define visibility of other. We don't have to do that, but it makes our form more performant.
export const schemaConditionals = {
seatChooser: (data) => data.tickets > 0,
__watch: ["tickets"]
} satisfies SchemaConditionals<typeof schema>
Here we inform the form, that only tickets
field should be watched for other fields visibility update.
Of course, schemaConditionals
should be provided both to client-side (as schemaConditionals
prop in ValidatedFormBase
) and server-side (as four arg to getFormDataBase
).
Sometimes we want more freedom and autogenerated form doesn't really suits us. For instance, we want to split fields to columns or style the whole things in some sophisticated way.
We can do this by simply providing children func to the ValidateFormBase
.
<ValidatedFormBase
mode="onChange"
translateFunc={translateFunc}
components={formComponents}
schema={schema}
successMessage={successMessage}
submitActionName={submitActionName}
>
{({ Form, ...control }) => {
<div>
<Form.firstName {...control} />
<Form.lastName {...control} className="test" />
</div>
}}
</ValidatedFormBase>
Form
gives us access to all the fields in the form and we can render them anywhere we want. Using control
is required as it makes sure that remix-hook-form still has an access to the form data.
[!TIP]
ValidatedFormBase
doesn't hint us if we use existing fields. There is however built-in typeFormFieldsChildrenFunc<Schema>
that can provide proper hinting. If you use custom wrapper forValidatedFormBase
(which is the recommended way anyway), you can type thechildren
with this typing.
If you spot a bug or would love to see some new feature in the package, do not hesitate to fill an issue.
Thanks!