Form
Build statically typed forms with ease. A refreshing alternative to existing form libraries.
This is an opinionated form library for any JavaScript environment, with focus on React and React Native. The main difference between this project and other form libraries out there, is the ability to define a form outside of your presentation layer and just map it inside a component later on. A form built this way will encapsulate all the necessary logic to handle validation, submission, error handling and so on. This leads to clean separation of concerns, easier testing, composability and reusability.
One of the coolest features of this library is the fully static form fields access, anything that can be caught at compile time does not have to be tested explicitly.
- Works everywhere, optimised for React and React Native
- Strong typings from tail to toe, for joyful developer experience
- Can be tested separately from the UI
- Treat forms as a service and reduce complexity inside components
- John Wick kind of crazy powerful validation options
- Fully static field access, forget your string based keys
- Many goodies like, status indicators, dirty fields, etc.
- Tested by an army of killer coding ninja monkeys with a test coverage of 100%
- Tons of other features and very easy customisation options
yarn
npm
yarn add @corets/form
npm install --save @corets/form
Seamless React integration is shipped in this package:
Ready to use form bindings for React, to get you started, can be found here:
Comes with built in support for the schema package for delightful validation logic:
Static fields access is powered by this library:
Use this example as a high level overview of what this library has to offer and whether it suits your personal preference. Here we are going to create a very basic form that has some validation logic and dispatches an HTTP request to a remote endpoint, for processing.
First, let's define our types:
export type User = {
uuid: string
firstName: string
lastName: string
}
export type CreateUserForm = {
firstName: string
lastName: string
}
export type CreateUserResult = {
success?: string
error?: string
user?: User
}
Next, define a method that is going to call the API:
export const createUser = async (data: CreateUserForm): Promise<User> =>
({ id: 1, ...data })
Now we can build the form logic:
import { createFormFromSchema } from "@corets/form"
import { object, value } from "@corets/schema"
export const createUserForm = () => {
return createFormFromSchema<CreateUserForm, CreateUserResult>(object({
firstName: value("").string().min(2).max(20).toTrimmed(),
lastName: value("").string().min(2).max(20).toTrimmed()
}))
.handler(async (form) => {
try {
const user = await createUser(form.get())
return { success: "User created", user }
} catch (error) {
return { error: "Could not create user" }
}
})
}
Now let's build the actual form:
import React from "react"
import { useForm } from "@corets/use-form"
import { useFormBinder } from "@corets/use-form-binder"
const CreateUserForm = () => {
const form = useForm(createUserForm)
const bind = useFormBinder(form)
const errors = form.getErrors()
const result = form.getResult()
const isSubmitting = form.isSubmitting()
return (
<form {...bind.form()}>
<div>{isSubmitting && "Loading..."}</div>
<div>{result?.success || result?.error }</div>
<div>
<input {...bind.input("firstName")} placeholder="First name"/>
<div>{form.getErrorsAt("firstName")}</div>
</div>
<div>
<input {...bind.input("lastName")} placeholder="Last name"/>
<div>{errors.getErrorsAt("lastName")}</div>
</div>
<button {...bind.button()}>Create</button>
</form>
)
}
In this example we are using the vanilla form binder shipped through the @corets/use-form-binder package, read more about form binders in the next section.
One of the unique features of this library is it's static field access. Normally you bind / get / set your form data using dynamic, string based keys, which breaks your compile time safety. This significantly increases the maintenance cost of forms in the future, since you always have to be extremely careful when changing any of the form fields or the form object structure.
There is a better way to do this! Below is a side by side comparison of a form using dynamic and static field access. Keep in mind that you have full IDE / autocomplete support when accessing form fields through the static facade.
static
dynamic
import { createForm, ObservableFormField } from "@corets/form"
const form = createForm({ some: { nested: "field" } })
const fields = form.getFields()
fields.some.nested.get().getValue()
fields.some.nested.get().setValue("new value")
import { createForm, ObservableForm } from "@corets/form"
const form = createForm({ some: { nested: "field" } })
form.getAt("some.nested")
form.setAt("some.nested", "new value")
You can read more about static accessors in the package docs:
This library was not designed for any specific framework. You should be able to use it everywhere where you can run JavaScript. This is why it does not ship any logic on how to connect to the UI out of the box. The @corets/use-form-binder package provides bindings for vanilla HTML elements, to get you started.
Given the nature of modern frontend development, projects use various component libraries and more often than not you have to write a custom input component. Obviously it is not possible to write one binder to rule them all. Therefore the most pragmatic approach is to create dedicated binders for various use cases. The good thing is that it is super easy to write your own binder!
Here is an example of a very basic, vanilla text field binder:
import { ObservableForm } from "@corets/form"
const createBinder = (form: ObservableForm) => ({
input: createInputBinder(form)
})
const createInputBinder = (form: ObservableForm) => (path: string) => {
return {
name: path,
value: form.getAt(path),
onChange: (e) => form.setAt(path, e.target.value),
}
}
That's all there is about it! The best thing is, no matter what component you are going to use, you can always make it work! ™ 😎
Now let's use that binder in our form:
import React from "react"
import { createForm } from "@corets/form"
import { useForm } from "@corets/use-form"
const Example = () => {
const form = useForm(() => createForm({ field: "value" }))
const bind = createBinder(form)
return <input {...bind.input("field")} />
}
Another way to create a binder is using the
Form.getFields()
method powered by the @corets/accessor library. You no longer need to use dynamic, string based keys to map the form fields, you can use a statically typed facade instead:import { ObservableFormField } from "@corets/form"
const createBinder = () => ({ input: inputBinder })
const inputBinder = (field: ObservableFormField) => {
return {
name: field.getKey(),
value: field.getValue(),
onChange: (e) => field.setValue(e.target.value),
}
}
Now let's use that binder in our form:
import React from "react"
import { createForm } from "@corets/form"
import { useForm } from "@corets/use-form"
const Example = () => {
const form = useForm(() => createForm({
some: { nested: { field: "value" } }
}))
const bind = createBinder(form)
const fields = form.getFields()
return <input {...bind.input(fields.some.nested.field.get())} />
}
You can reduce the number of re-renders in big forms, by wrapping your UI blocks into the
<Memo/>
component from the @corets/memo package. Most likely you will never need this, but if you do, you are covered.import React, { useState } from "react"
import { createForm } from "@corets/form"
import { useForm } from "@corets/use-form"
import { Memo } from "@corets/memo"
const Example = () => {
const form = useForm(() => createForm({ field1: "foo", field2: "bar" }))
const [someValue, setSomeValue] = useState("some state")
return (
<form>
<Memo deps={form.getDeps("field1")}>
This section will only ever re-render when some of shared form properties change,
like: `submitting`, `submitted` or `result`, or when one of the field
related properties receives a change specific to this field,
like: `errors`, `values`, `changedFields` or `dirtyFields`.
</Memo>
<Memo deps={form.getDeps(["field1", "field2"])}>
This section will change when one of the two fields receives a relevant change.
</Memo>
<Memo deps={form.getDeps(["field1", "field2"], { errors: false })}>
This block will NOT re-render if there is an error for one of the two fields.
</Memo>
<Memo deps={[...form.getDeps(["field1", "field2"]), someValue]}>
Include an aditional, custom, value to the list of dependencies for a re-render.
</Memo>
</form>
)
}
You can read more about <Memo/> in the package docs:
When writing unit tests, make sure that you set the
debounce
config property to 0
. This disables throttling of the state changes and allows you to write tests in a synchronous manner.import { createForm } from "@corets/form"
const form = createForm().configure({ debounce: 0 })
Create a new form instance:
import { createForm } from "@corets/form"
const form = createForm({ data: "foo" })
Create a new form without the factory function:
import { Form } from "@corets/form"
const form = new Form({ data: "foo" })
Create a form instance with a specific type:
import { createForm } from "@corets/form"
type MyForm = { data: string }
const form = createForm<MyForm>({ data: "foo" })
Specify form result type:
import { createForm } from "@corets/form"
type MyForm = { data: string }
type MyFormResult = { result: string }
const form = createForm<MyForm, MyFormResult>({ data: "foo" })
Create a new form instance based on a schema definition from the @corets/schema package. This is a convenient helper that allows you to avoid unnecessary boilerplate code when defining initial values for a form. Instead of creating an object, that adheres to the specific form type, with the initial values, you can define those initial values inside the schema definition itself.
Here is a side by side comparison of the two different approaches:
createFormFromSchema
createForm
import { createFormFromSchema } from "@corets/form"
import { object, schema } from "@corets/schema"
type MyForm = { data: string }
type MyFormResult = { result: string }
const formSchema = object<MyForm>({
data: schema("foo").string().min(2)
})
const form = createFormFromSchema<MyForm, MyFormResult>(formSchema)
import { createForm } from "@corets/form"
import { object, string } from "@corets/schema"
type MyForm = { data: string }
type MyFormResult = { result: string }
const formSchema = object<MyForm>({ data: string().min(2) })
const initialValues: MyForm = { data: "foo" }
const form = createForm<MyForm, MyFormResult>(initialValues).schema(formSchema)
You should always use createFormFromSchema instead of of createForm where possible.
You can read more about schemas in the package docs:
Alter form behaviour by providing configuration overrides:
import { createForm } from "@corets/form"
const form = createForm().configure({
// run schema sanitizers on form submit() and validate()? (default: true)
sanitize: true,
// validate on form submit()? (default: true)
validate: true,
// validate fields immediately after a change? (default: true)
reactive: true,
// delay invocation of listeners after a change (default: 10ms)
debounce: 10,
})
Specify a handler that is called whenever the form is submitted:
import { createForm } from "@corets/form"
type MyForm = { data: string }
type MyFormResult = { result: string }
const form = createForm<MyForm, MyFormResult>({ data: "foo" })
.handler(async (form) => {
const result: MyFormResult = { result: "some value" }
return result
})
Result returned from a handler is also returned from the
Form.submit()
method and is available through the Form.getResult()
method later on.Specify a validation function that is called before the form is submitted or after a form field has been changed, depends on your form config:
import { createForm } from "@corets/form"
type MyForm = { data: string, nested: { field: string } }
const form = createForm<MyForm>({ data: "foo", nested: { field: "bar" } })
.validator(async (form) => {
const values = form.get()
// run some validation logic ...
return {
"foo": ["Invalid value"],
"nested.field": ["Invalid value"]
}
})
If you happen to return validation errors for the whole form, but the validator has been invoked after a field change, errors relating to fields that are not yet changed, will be omitted. Validator will be called in addition to the schema that you might have configured, possible errors will be merged.
Configure a validation schema for your form:
import { createForm } from "@corets/form"
import { object, string } from "@corets/schema"
type MyForm = { data: string, nested: { field: string } }
const schema = object({
data: string().min(2),
nested: object({
field: string().max(2).toTrimmed()
}
)})
const form = createForm<MyForm>({
data: "foo",
nested: { field: "bar" }
}).schema(schema)
Schemas are a delightful and powerful way to write your validation logic. They are feature loaded, very flexible and customisable. If you happen to provide a schema and a validator function, both will be invoked and the resulting errors will be merged.
Sanitizers that have been added to the schema will be called before any kind of validation happens and will alter the form values if necessary. This applies to the complete validation cycle and not to the reactive one (where validation is triggered only for the changed field).
You can read more about schemas in the package docs:
Invoking the validate method will trigger the validation process where values get sanitized, schema and validator get called, resulting errors get merged, and so on:
import { createForm } from "@corets/form"
import { object, string } from "@corets/schema"
type MyForm = { data: string, nested: { field: string } }
const schema = object<MyForm>({
data: string().min(2),
nested: object({
field: string().max(2)
}
)})
const form = createForm<MyForm>({
data: "foo",
nested: { field: "" }
}).schema(schema)
const errors = await form.validate()
if ( ! errors) {
// continue ...
}
Errors object will look something like this:
{
"data": ["Must be at least \"2\" characters long"],
"nested.field": ["Required", "Must be less than \"2\" characters long"],
}
You can access errors anytime later:
const errors = form.getErrors()
You can choose to validate and sanitize only fields that have been changed:
const errors = await form.validate({ changed: true })
You can choose to disable sanitization of values:
const errors = await form.validate({ sanitize: false })
You can choose not to persist any possible errors and just do a dry run:
const errors = await form.validate({ persist: false })
Submitting a form will invoke the validator, schema and handler, depending on the configuration. Whatever is returned from the handler will be returned from the submit method itself and is also stored on the form object for later access.
import { createForm } from "@corets/form"
type MyForm = { data: string }
type MyFormResult = { result: string }
const form = createForm<MyForm, MyFormResult>()
.handler(async () => {
return { result: "foo" }
})
const result = await form.submit()
You can access result anytime later:
const result = form.getResult()
Access validation errors:
const errors = form.getErrors()
Submit without validation:
const result = form.submit({ validate: false })
Submit without sanitization:
const result = form.submit({ sanitize: false })
Validate and sanitize only those form fields that have been changed:
const result = form.submit({ changed: true })
Subscribe to any changes on the form object:
import { createForm } from "@corets/form"
const form = createForm({})
const unsubscribe = form.listen(form =>
console.log("something has changed")
)
unsubscribe()
Set a custom debounce interval for your listener:
import { createForm } from "@corets/form"
const form = createForm({})
const unsubscribe = form.listen(form =>
console.log("something has changed")
, { debounce: 100 })
Disable debounce for your listener:
import { createForm } from "@corets/form"
const form = createForm({})
const unsubscribe = form.listen(form =>
console.log("something has changed")
, { debounce: 0 })
Get all form values:
import { createForm } from "@corets/form"
const form = createForm({ data: "foo" })
const values = form.get()
Get form values at a specific path:
import { createForm } from "@corets/form"
const form = createForm({ nested: { data: "foo"} })
const value = form.getAt("nested.data")
Replace all form values with the new ones:
import { createForm } from "@corets/form"
const form = createForm({ data: "foo" })
form.set({ data: "bar" })
This method overrides all form values and does not track any dirty or changed fields. Use the Form.setAt() method instead, if you want to track dirty and changed fields.
Replace form values at a specific path:
import { createForm } from "@corets/form"
const form = createForm({ nested: { data: "foo" } })
form.setAt("nested.data", "bar")
Add some data to the form by doing a merge:
import { createForm } from "@corets/form"
const form = createForm({ data1: "foo" })
form.put({ data2: "bar" })
Unlike Form.set(), this method does not override all the form data, but will rather merge it instead. This method does not track any dirty or changed fields. Use Form.setAt() method, if you want to track dirty and changed fields.
Reset form values, errors, status indicators, etc. This will reset form values to the initial values (the one that you've passed into the
createForm()
function):import { createForm } from "@corets/form"
const form = createForm({ data: "foo" })
form.clear()
You might want to call this after a successful form submission.
You can replace initial values while clearing the form:
import { createForm } from "@corets/form"
const form = createForm({ data: "foo" })
form.clear({ data: "bar" })
Get all form errors:
import { createForm } from "@corets/form"
const form = createForm()
const errors = form.getErrors()
Get all errors at a specific path:
import { createForm } from "@corets/form"
const form = createForm({ nested: { data: "foo" } })
const errors = form.getErrors("nested.data")
Replace all errors:
import { createForm } from "@corets/form"
const form = createForm()
form.setErorrs({ field: ["first error", "second error"] })
Replace all errors at a specific path:
import { createForm } from "@corets/form"
const form = createForm()
form.setErrorsAt("nested.field", ["first error", "second error"])
Add some new errors:
import { createForm } from "@corets/form"
const form = createForm()
form.addErrors({ field: ["first error", "second error"] })
Add some new errors at a specific path:
import { createForm } from "@corets/form"
const form = createForm()
form.addErrorsAt("nested.field", ["first error", "second error"])
Check if there are any errors:
import { createForm } from "@corets/form"
const form = createForm()
const hasErrors = form.hasErrors()
Check if there are any errors at a specific path:
import { createForm } from "@corets/form"
const form = createForm()
const hasErrors = form.hasErrorsAt("nested.field")
Clear all errors:
import { createForm } from "@corets/form"
const form = createForm()
form.clearErrors()
Clear all errors at a specific path:
import { createForm } from "@corets/form"
const form = createForm()
form.clearErrorsAt("nested.field")
Check if any of the form fields are dirty (have been written to):
import { createForm } from "@corets/form"
const form = createForm()
const isDirty = form.isDirty()
Works only for fields that have been set using the Form.setAt() method. Methods like Form.set() or Form.put() do not track this kind of changes.
Check if a specific field is dirty:
import { createForm } from "@corets/form"
const form = createForm()
const isDirty = form.isDirtyAt("nested.field")