In the last few months I’ve been working a lot with forms. I had to do a large refactoring in one of AUTO1’s applications, and through all the research, I also encountered the term subform. The concept was troublesome not only for me.
I found more than one issue on the Formik repository about developers asking for help.
In this article I'll try to clarify this concept and show you how to use it properly.
What the heck is a subform anyway?
If you’ve ever worked on a medium-large project with a reasonable amount of forms, you might have probably noticed that you could potentially reuse a lot of fields, sometimes even entire forms!
That’s the core of the subform concept: reusable components (fields or group of fields) that can be composed together to create bigger forms.
At the beginning I had a lot of questions in my mind like:
- How can I handle validation?
- How can I handle form state?
- How can I stay flexible?
These questions and many others appeared while I was refactoring existing codebase. Many similar questions created by other developers appeared in the Formik repository as issues.
Don’t get me wrong: implementing scalable validation for a single form is not that hard. The complicated thing is to keep validation and state flexible while you write your subforms. What does it mean? We’ll see that in a moment.
How Formik can help
Formik is an amazing library, one of the bests for this job, and here’s what it brings to the table:
Auto-connected Field component
Formik provides a Field component that, thanks to the React Context API, will be automatically connected to the Form component that wraps our Field, regardless of how deep our Field is in the tree.
Yup validation schema
Yup is a library to create validation schemas. This library is very similar to the prop-types of React, so it is very simple to start using it.
Formik supports Yup natively, so you just need to define the schema and pass it to Formik, it will take care of the rest.
So this is how Formik will make our life easier not only while creating subforms but also when we’ll need to maintain them!
Enough talking, show me the code
Let’s create our first subform! Before we start, we need to clarify what our subform will be responsible for:
- Provide a basic validation schema
- Provide some default values (required by Formik)
- Provide the list of its fields (in case we need to access the data from outside, as we’ll see later)
- Provide a React component that will render the subform to the user and its fields’ errors
In our case, we're going to create a form that allows to create a new user.
We'll need to display the following fields:
- First name
- Last name
- Password
Just keep it simple. So, let’s create our NewUserSubForm
directory and our fieldsNames
file. It’s just a file that exports constants, don’t worry. It will look like this:
// UserSubform/fieldsNames.js
export const FIRST_NAME = "firstName"
export const LAST_NAME = "lastName"
export const EMAIL = "email"
export const PASSWORD = "password"
Defining the validation schema
// UserSubform/validationSchema.js
import * as yup from "yup"
import { FIRST_NAME, LAST_NAME, EMAIL, PASSWORD } from "./fieldsNames"
const MIN_PASSWORD_LENGTH = 8
const REQUIRED_MESSAGE = "This field is required"
const INVALID_EMAIL_FORMAT = "Invalid email format"
const PASSWORD_TOO_SHOWRT = `The password must be at least ${MIN_PASSWORD_LENGTH} characters long`
export default yup.object({
[FIRST_NAME]: yup.string().required(REQUIRED_MESSAGE),
[LAST_NAME]: yup.string().required(REQUIRED_MESSAGE),
[EMAIL]: yup
.string()
.required(REQUIRED_MESSAGE)
.email(INVALID_EMAIL_FORMAT),
[PASSWORD]: yup
.string()
.required(REQUIRED_MESSAGE)
.min(MIN_PASSWORD_LENGTH, PASSWORD_TOO_SHOWRT),
})
We’ve just defined our validation schema regardless the markup of our form. This file just contains all the validation logic and has one responsibility. If in the future we’ll need to add some more validation options, we’ll just need to change it here.
Now it’s time for the default values. Initial values are required from Formik because it uses controlled inputs. So, if you don’t specify those values, you’ll get and error from React as soon as you try to change the content of the input.
// UserSubform/defaultValues.js
import { FIRST_NAME, LAST_NAME, EMAIL, PASSWORD } from "./fieldsNames"
export default {
[FIRST_NAME]: "",
[LAST_NAME]: "",
[EMAIL]: "",
[PASSWORD]: "",
}
Rendering the subform
And now the master piece: The React component. Remember: we just need to use the Fields and not the Formik or the Form components.
// UserSubform/index.js
import React, { Fragment } from "react"
import { Field, ErrorMessage } from "formik"
import { FIRST_NAME, LAST_NAME, EMAIL, PASSWORD } from "./fieldsNames"
export default class NewUserSubForm extends React.Component {
render() {
return (
<Fragment>
<Field component="input" name={FIRST_NAME} />
<ErrorMessage name={FIRST_NAME} />
<Field component="input" name={LAST_NAME} />
<ErrorMessage name={LAST_NAME} />
<Field component="input" name={EMAIL} />
<ErrorMessage name={EMAIL} />
<Field component="input" name={PASSWORD} />
<ErrorMessage name={PASSWORD} />
</Fragment>
)
}
}
And that’s it. In this phase we can test every single part of our subform: validation, the default values schema and the React component.
A piece of advice: Formik sets the fields’ values in its state using the name
property, but the cool thing is that it uses it like the Lodash set
function. It means that we can write the name of a field like this: user.firstName
. In this way Formik will create an object in its state called user
, and then a property inside of user
called firstName
that will contain the value of our field.
This mechanism gives us power to improve the flexibility of our subform. How?
Making a subform flexible
Let’s edit our component in a way that it will accept an optional property called namespace
. If received, the component will prepend the namespace to every field name. In this way it will be easier to wrap all the subform’s values under a certain object in the main form.
// UserSubform/index.js
import React, { Fragment } from "react"
import PropTypes from "prop-types"
import { Field, ErrorMessage } from "formik"
import { FIRST_NAME, LAST_NAME, EMAIL, PASSWORD } from "./fieldsNames"
export default class NewUserSubForm extends React.Component {
static propTypes = {
namespace: PropTypes.string,
}
withNamespace(fieldName) {
const { namespace } = this.props
return namespace ? `${namespace}.${fieldName}` : fieldName
}
render() {
const { withNamespace } = this
return (
<Fragment>
<Field component="input" name={withNamespace(FIRST_NAME)} />
<ErrorMessage name={withNamespace(FIRST_NAME)} />
<Field component="input" name={withNamespace(LAST_NAME)} />
<ErrorMessage name={withNamespace(FIRST_NAME)} />
<Field component="input" name={withNamespace(EMAIL)} />
<ErrorMessage name={withNamespace(FIRST_NAME)} />
<Field component="input" name={withNamespace(PASSWORD)} />
<ErrorMessage name={withNamespace(FIRST_NAME)} />
</Fragment>
)
}
}
We don’t need to do that with the other parts of the subform, the main form will be responsible for that. And, about the main form, let’s see how to implement it!
The main form
Finally, we’re going to create our main form component. Let’s define its responsibilities just like we did with our subform. The main form will be responsible for:
- Compose the validation
- Compose the React components
- Compose and eventually overwrite the default values
- Orchestrate all the above elements in the right way (if we add a namespace for a subform we should put its validation schema under the same namespace)
- Handle the submission of the form
- Handle the display logic of the server-side errors (and all the form level errors)
It's a lot of responsibilities, and that’s all right. The main form represents a specific point in the UI/UX where the user needs to insert some data. In our case, it could be a registration form, but it could also be a registration combined with a purchase, just like when you buy something from amazon and you agree to sign up in the process.
The point is: A Form is a unique component that represent a specific use case, so it has to be designed accordingly. That’s why it makes no sense to create a “god-form” component with hundreds of props that decides which endpoint the form is going to use. It just creates useless complexity.
In my opinion, the best way to organize this approach is to create a folder where you’ll store all your subforms. Every subform will be represented by its directory and it will contain all its parts: validation, values, fields and the React component.
A main form, instead, should be created ad-hoc to fit the needs of a certain use case, for example inside a certain route.
So, with that in mind, let’s proceed to the implementation. We’ll have our directory called registrationForm
and it will have the same parts of a subform:
Fields Names
// CreateNewUserRoute/form/fieldsNames.js
export {
FIRST_NAME,
LAST_NAME,
EMAIL,
PASSWORD,
} from "./subforms/NewUserSubForm/fieldsNames"
export const USER = "user"
Validation
// CreateNewUserRoute/form/validationSchema.js
import * as yup from "yup"
import { USER } from "./fieldsNames"
import userValidationSchema from "./subforms/NewUserSubForm/validationSchema"
export default yup.object({
[USER]: userValidationSchema,
})
Default values
// CreateNewUserRoute/form/defaultValues.js
import { USER } from "./field Names"
import userDefaultValues from "./subforms/NewUserSubForm/defaultValues"
export default {
[USER]: userDefaultValues,
}
The React component
// CreateNewUserRoute/form/index.js
import React from "react"
import { Formik, Form } from "formik"
import NewUserSubForm from "./subforms/NewUserSubForm"
import validationSchema from "./validationSchema"
import defaultValues from "./defaultValues"
import { USER } from "./fieldsNames"
import ErrorBanner from "path/to/components/ErrorBanner"
export default class NewUserSubForm extends React.Component {
state = {
unknownErrors: null,
}
onSubmit = async (values, { setSubmitting, setErrors }) => {
try {
// Send values somehow
await sendForm(values)
} catch (e) {
// Map and show the errors in your form
const [formErrors, unknownErrors] = mapErrorsFromRequest(e)
setErrors(formErrors)
this.setState({
unknownErrors,
})
} finally {
setSubmitting(false)
}
}
render() {
const { unknownErrors } = this.state
return (
<Formik
onSubmit={this.onSubmit}
initialValues={defaultValues}
validationSchema={validationSchema}
>
{() => (
<Form>
{unknownErrors && <ErrorBanner errors={unknownErrors} />}
<NewUserSubForm namespace={USER} />
</Form>
)}
</Formik>
)
}
}
And that’s it! Of course, this is a very simple example, you could have different needs.
Helpful advices on creating subforms
I want to leave you with some advices that helped me while I was refactoring my codebase. It's good to have them in mind to ease the process of code refactoring.
A subform should have only first level values in its state
A subform should have only first level values in its state, which means that, when you design a subform, you shouldn’t get crazy about the shape of its values. It should be a flat object and every key should contain the field value.
This way it’s so much easier to write validations, default values and error handling (and why not, also the React component).
You can avoid this advice only when you are using a subform into you subform. For example, let’s say you have an address subform. It has a lot of fields and a complex validation schema. In that scenario all the logic will be handled by the address subform and you’ll just need to orchestrate it in your own subform, just like you would do in the main form.
Keep the validation schema extensible and scalable
I didn’t do it in this article but the idea is to export a function instead of a schema. This function will accept parameters that will define the schema that you’ll get.
In this case you can toggle the “required” validation in some cases, or other kinds of validation.
Example: let’s say that we want to make the “lastName” field optional, but not always. That’s how you could define your schema:
// UserSubform/validationSchema.js
import * as yup from "yup"
import { FIRST_NAME, LAST_NAME, EMAIL, PASSWORD } from "./fieldsNames"
const MIN_PASSWORD_LENGTH = 8
const REQUIRED_MESSAGE = "This field is required"
const INVALID_EMAIL_FORMAT = "Invalid email format"
const PASSWORD_TOO_SHOWRT = `The password must be long at least ${MIN_PASSWORD_LENGTH} characters`
export default function validationSchema(
mandatoryFields = { [LAST_NAME]: true }
) {
return yup.object({
[FIRST_NAME]: yup.string().required(REQUIRED_MESSAGE),
[LAST_NAME]: yup.lazy(() =>
mandatoryFields.lastName
? yup.string().required(REQUIRED_MESSAGE)
: yup.string()
),
[EMAIL]: yup
.string()
.required(REQUIRED_MESSAGE)
.email(INVALID_EMAIL_FORMAT),
[PASSWORD]: yup
.string()
.required(REQUIRED_MESSAGE)
.min(MIN_PASSWORD_LENGTH, PASSWORD_TOO_SHOWRT),
})
}
Now you have a scalable validation schema.
In this way you can always decide if a certain field is required or not. Also, you can extend that function to add parameters. In this way, if your subform scales, you’ll just need to add parameters and adjust the code in the subform accordingly, but every single form that uses your subform won’t be affected by these changes because everything is retro compatible.
Conclusions
Dealing with forms is not easy. Of course, there are simple cases, but there are also trickier ones. This was my way to organize the code, and of course, it’s not perfect.
I’m sure there are other thousands amazing ways to solve this problem better than this, but for now, this is the best way I’ve found to keep everything testable, maintainable and scalable.
I hope this will help you, and if you have a better approach, I’m looking forward to reading it!
Until next time, happy hacking!
Top comments (0)