Balance Logo
Balance
Reckon Design System
Open playroom

Forms

The forms package provides utilities to build and validate forms easily.
Install
pnpm add @balance-web/forms
Import usage
import {
multiselect,
text,
multilineText,
email,
currency,
duration,
boolean,
string,
date,
dateRange,
empty,
select,
autocomplete,
scalar,
object,
array,
field,
useFormSnapshot,
formStateToFormValue,
useSubmit,
isFormValueEqualToValue,
useIsFormEqualToInitialValue,
countVisibleValidationErrors,
getInitialState,
resetForm,
useForm,
validation,
validate,
getValidationMessages
} from '@balance-web/forms';
  • Code
  • API

@balance-web/forms provides a mental model and set of state primitives for implementing complex composable and type-safe forms.

Getting Started

To create a form, you need to define your form schema.

Edit in Playroom

Initializing with values

To prefill a form, you have to pass an object (of shape form) to the 2nd parameter of useForm.

Note: The initial value object will only be used on the first render. For example passing the result of an api call as the initial value to useForm will not work as shown below:

const profileForm = field.object({
email: field.text(),
firstName: field.text(),
lastName: field.text(),
});
const [profile, setProfile] = useState();
useEffect(() => {
const loadProfile = async () => {
setProfile({ email: 'email@provider.com', firstName: 'r2', lasName: 'd2' });
return true;
};
loadProfile();
}, []);
const form = useForm(profileForm, profile);
// fields below will be empty!
return (
<FieldStack>
<Field label="Email">
<TextInput {...form.fields.email.props} />
</Field>
<Field label="First name">
<TextInput {...form.fields.firstName.props} />
</Field>
<Field label="Last name">
<TextInput {...form.fields.lastName.props} />
</Field>
</FieldStack>
);

To remedy this, we should not load any data inside the form component. Initial values should be loaded further up the component tree and passed to the form component.

Fields

There are currently three types of fields scalar, object and array.

Scalars

A scalar field corresponds to one bit of input. While in most cases, this will be an input element, this is not always true. It's very important to note that scalar fields can have arrays and objects and etc. inside of them.

Our scalar fields include:

  • autocomplete
  • boolean
  • currency
  • duration
  • date
  • dateRange
  • email
  • empty
  • ISODate
  • ISODateRange
  • multilineText
  • select
  • text

Objects

Object fields let you compose fields together. Just like you can compose React components, you can use these fields to compose fields.

Arrays

Array fields let you create arrays of Object, Array or Scalar fields.

Validation

By default, validation is only triggered on form submission. It's still possible to do onBlur validation by manually providing an onBlur function to your component and running the yourField.props.blur() function to set the field as touched which triggers the validation.

Validation is done slightly differently for object and scalar fields.

Scalar Validation

For scalar fields, you can use the validate import from @balance-web/forms.

You need to call it with a callback that accepts the value of the field and then return the validated value.

NOTE: You cannot change the value. The reason you must return the value is so that you can refine the type of the value when it's valid.

You can then call various functions on validate. Under the hood, all of these call validate.error. validate.error throws a specific error when called which is caught and the field is treated as invalid. This allows for simple composition of validators because if one validator fails no code after it will execute.

To see the full list of validators available, use your editor autocomplete.

Object Validation

Object validation is quite similar to scalar validation though it can access the values of other fields in the object.

You might be wondering why you can't just access other fields in validation functions on scalar fields. The reason you can't do that is that fields are intended to be composable so you can write one and then reuse it in another form. This is very similar to React components, they don't know where they're rendered and they can't ask questions like "what are the props of the component that's rendering me?" because then they'd be deeply coupled to where they're rendered. This is important so that you can reuse and compose together different forms. There's also another important reason: It's much easier reason about when you don't know about where your form is used, you can focus on building a small isolated part of something without having to keep the entire thing in your head.

With that context in mind, the way you do validation by passing an object to the validate option on an object field. This object mirrors the shape of the form. So in the example below, we are adding a validator to the passwordConfirmation field. Notice that instead of calling validate, we're calling validate.object and we recieve the value but we also recieve the value of the object itself as an argument. An important thing to call out is that the value(as in the first argument) is the validated value of that field. So in the example below, the validate.required check in the passwordConfirmation field is run first and then the check that the passwordConfirmation field is equal to the password field happens if the validate.required check succeeds. This is also true as you have validators up the form tree. Said another way: Validators are called from the bottom of the tree up and if one fails, none of the validators higher up the tree will be called.

Curious why this is an object rather than a big function that recieves all the values?

This is done so that when async validators are allowed, a user doesn't have to wait for the async validator to finish to see any fields and instead only the specific scalar field will be pending.

Note: You cannot actually validate an object per se, you can only validate a scalar field based on other fields. If you must represent an error on the object itself rather than a particular scalar field, you can use field.empty() which itself has no value but you can put the validation errors on that field and then render the error in whatever way you want. But it's very important to note that you should avoid doing this because a user should see the error in the place that's causing it and where they can fix it.

Note: currently, the return type of object validation functions does NOT refine the type of the validated value, this will most likely change in the future.

Validation based on values outside the form

For validation based on values outside the form, you pass a function to useForm's 1st parameter which recieves values required to perform the validation. In the example below, we pass in type to the signupForm function which is used to determine if the firstName and lastName fields are required.

const signupForm = (type) => {
return field.object({
email: field.text({
validate: validate((value) => {
validate.required(value);
return value;
}),
}),
password: field.text({
validate: validate((value) => {
validate.required(value);
return value;
}),
}),
firstName: field.text({
validate: validate((value) => {
if (type !== 'quick') validate.required(value);
return value;
}),
}),
lastName: field.text({
validate: validate((value) => {
if (type !== 'quick') validate.required(value);
return value;
}),
}),
});
};
const form = useForm(signupForm('quick'));
return (
<FieldStack>
<Field label="Email">
<TextInput {...form.fields.email.props} />
</Field>
<Field label="Password">
<TextInput type="password" {...form.fields.password.props} />
</Field>
<Field label="First name">
<TextInput {...form.fields.firstName.props} />
</Field>
<Field label="Last name">
<TextInput {...form.fields.lastName.props} />
</Field>
</FieldStack>
);

useSubmit

When you're building a form that has an explicit submit action, you should use useSubmit.

useSubmit does three things:

  • The callback to useSubmit is only called when the form is valid
  • When the form is not valid and a form is submitted, the validation messages for all fields will be shown
  • If there are any errors, it will scroll to the first error on the screen. You can opt out of this behaviour by setting disableScrollToFirstError to true in the options.
useSubmit(form, () => {}, { disableScrollToFirstError: true });

A very important note here is that you should NOT make submit buttons disabled when the form is not valid

If a design shows that a submit button is disabled, this is likely out of date. DO NOT MAKE SUBMIT BUTTONS DISABLED

Curious why we don't use disabled buttons when a form is invalid?

When a submit button is disabled, that just tells the user "the form is invalid"(though this isn't completely true, it could indicate some other reason that they can't submit the form which may not be because of their input) but knowing that is not useful to them. They want to know how to fix the form. Instead, when the user clicks the submit button and the form is invalid it makes all of the validation messages visible. (A note: What this means is that users will only see the validation messages when they've indicated "I think what I've entered here is correct" by them focusing and then blurring the field or attempting to submit the form because we don't want to bombard them with messages when they're still filling it in and have done nothing wrong but if they have said "I think what I've entered here is correct," we should then show them why their input is not correct)

countVisibleValidationErrors

To get the number of visible validation errors in a form, you can use countVisibleValidationErrors.

When using TabbedDrawer, the result of this for each section of the form should be the passed to the issueCount of the corresponding tab.

resetForm

To reset a form, you can use resetForm. You can also pass an initial value like the second argument of useForm.

useIsFormEqualToInitialValue

To get if the current value is equal to the initial value of the form, you can use useIsFormEqualToInitialValue.

Note: If you're looking to use this to determine if there are unsaved changes then it's recommended you use useFormSnapshot

useFormSnapshot

To track the dirty state and roll back to old form states (when discarding temporary forms), you can use useFormSnapshot.

With initial state ready on mount

When async loading initial form state

This is particularly useful when your form state is already initialised but you're waiting for a server response to fill in the initial data.

Updating form state programatically

Generally, the state of the form will be updated by the inputs, you can also update the state programatically using form.setState though. This works on any part of the form. You also need to spread the state which you can access from form.state.

Type Utilities

@balance-web/forms exposes some type utilities to get various types from form schemas.

import {
field,
Form,
FormState,
FormValue,
ValidatedFormValue,
InitialValueInput,
} from '@balance-web/forms';
const loginForm = field.object({
email: field.text({
validate: validate((value) => {
validate.required(value);
return value;
}),
}),
password: field.text({
validate: validate((value) => {
validate.required(value);
return value;
}),
}),
});
// this is equivalent to the result of useForm/form.fields[key]
type LoginForm = Form<typeof loginForm>;
// this is the state of the form (form.state)
type LoginFormState = FormState<typeof loginForm>;
// this is the value of the form (form.value)
type LoginFormValue = FormValue<typeof loginForm>;
// this is the validated value of the form (form.value when form.validity === 'valid')
// this type will often be equivalent to FormValue<...>
// when fields have validation that _refines the type_ of a value(e.g. validate.maybeNumber), it will be different though
type ValidatedLoginFormValue = ValidatedFormValue<typeof loginForm>;
// this is equivalent to the second argument of useForm/resetForm
// it has all the fields that accept undefined as initial values optional and etc.
type LoginFormInitialValueInput = InitialValueInput<typeof loginForm>;
Copyright © 2024 Reckon. Designed and developed in partnership with Thinkmill.
Bitbucket logoJira software logoConfluence logo