@bytesoftio/form
TypeScript icon, indicating that this package has built-in type declarations

3.11.0 • Public • Published

@bytesoftio/form

Installation

yarn add @bytesoftio/form or npm install @bytesoftio/form

Table of contents

Description

This package provides a very convenient abstraction for forms. I never liked the idea of forms being defined and handled entirely inside the presentation layer, so I came up with this approach.

A form should be a first class citizen inside a project, kinda like a service. It should:

  • Have an initial state
  • Have a way to reset to initial state
  • Have validation logic
  • Have a handler
  • Have things like loading indicators, dirty fields, etc.
  • Have an easy way to bind to input elements
  • Be typed properly

Form logic consists mainly of these steps:

  • Define form data structure
  • Provide initial state
  • Define validation logic
  • Define handler logic
  • Provide status messages from within a handler like success, error, etc.
  • Share form submission result

A form with all of these things should be treated like a regular service, it has no place inside a presentation component. A component should simply bind form data to according input elements, consume validation errors and status messages, block form during submission and other purely presentation related stuff.

If you agree at least with some of the points above, stay with me, this library totally changed how I work with forms and might change your approach as well.

Quick start

Let's create a very simple form, where you can create a new user through an api call, with validation, etc. Keep in mind that this example could be much shorter in terms of lines of code It all depends on how much types you want to write. I always try to use as precise types as possible, so there is some boilerplate code around. I think it totally pays off in the long run though.

// types.ts

// sample data type that you want to create through an api call
export type User = {
  uuid: string
  firstName: string
  lastName: string
}

// this is what the form looks like
export type CreateUserForm = {
  firstName: string
  lastName: string
}

// meaningful feedback for form consumers
export type CreateUserResult = {
  success?: string
  error?: string
  user?: User
}
// api.ts

import { User, CreateUserForm } from "./types"

// simulate api call for demo purposes
const createUser = async (data: CreateUserForm): Promise<User> => ({ id: 1, ...data })
// form.ts

import { createForm } from "@bytesoftio/form"
import { object, string } from "@bytesoftio/schema"
import { CreateUserForm, CreateUserResult } from "./types"
import { createUser } from "./api.ts"

// a dedicated form factory
export const createUserForm = () => {
  // create a new form with initial state
  return createForm<CreateUserForm, CreateUserResult>({
    firstName: "",
    lastName: ""
  })
    // using @bytesoftio/schema package for validation
    .schema(object({
      firstName: string().min(2).max(20),
      lastName: string().min(2).max(20)
    }))
    // add form handling logic
    .handler(async (form) => {
      try {
        // pretend to make an api call here 
        const user = await createUser(form.values.get())

        return { success: "User created", user }
      } catch (error) {
        return { error: "Could not create user" }
      }
    })
}

The @bytesoftio/use-form package should be used to consume forms in React.

// component.tsx

import React from "react"
import { useForm } from "@bytesoftio/use-form"
import { createUserForm } from "./form"

const CreateUserForm = () => {
  // create a new form instance and consume it for proper re-renders when state changes
  // also returns a binding untility to connect input elements with form values
  const [form, bind] = useForm(createUserForm)
  // grab what you need from various state objects
  const [errors, result] = [form.errors, form.result.get()]

  return (
    <form {...bind.form()}>
      <div>{result?.success || result?.error }</div>

      <div>
        <input {...bind.input("firstName")} placeholder="First name"/>
        <div>{errors.getAt("firstName")}</div>
      </div>

      <div>
        <input {...bind.input("lastName")} placeholder="Last name"/>
        <div>{errors.getAt("lastName")}</div>
      </div>

      <button {...bind.button()}>Create</button>
    </form>
  )
}

Form config

Form behaviour can be altered through several config options:

import { createForm } from "@bytesoftio/form"

const form = createForm({})
	.configure({
    // run validation whenever from state changes
    validateOnChange: true,
    // if validateOnChange is set to true, define whether all fields should
    // be validated or only those that have been changed through user input
    validateChangedFieldsOnly: false,
    // run validattions before submitting form
    validateOnSubmit: true
  })

Form values

All the form values are stored inside the ObservableFormValues object. It comes with a few convenient helper methods.

import { createForm } from "@bytesoftio/form"

const initialState = { field1: "", field2: "", field3: "" }
const form = createForm(initialState)

// retrieve all of the form values
form.values.get()

// replace all form values with new one
form.values.set({ field1: "foo", field2: "bar" })

// add / replace some of the values with new one
form.values.add({ field3: "baz" })

// reset values back to its initial state
form.values.reset()

// alternatively you can provide new state that should be used as initialState
form.values.reset({ field1: "-", field2: "-", field3: "-" })

// retrieve a spefcific form field value
form.values.getAt("path.to.field")

// replace a specific form field value
form.values.setAt("path.to.field", "value")

// check if form has a certain value
form.values.hasAt("path.to.field")

Validation logic

Form can be validated in several ways. Mostly you'll want to use the schema method. It takes as schema description object produced by the @bytesoftio/schema package. But it is also possible to provide a custom validation function.

Lets take a look at the custom validate function first. A validation function can be sync or async and must return either undefined / nothing in case of a successful validation, or an error object of type ValidationResult that you can find in the @bytesoftio/schema package.

import { createForm } from "@bytesoftio/form"

const form = createForm({ 
  title: "", 
  user: { firstName: "" }
})
  .validator(async (form) => {
    const values = form.values.get()

    // some validation logic ...
    
    return {
      title: ["Value missing"],
      "user.firstName": ["Value missing"]
    }
})

Additionally, it is possible to provide a validation schema powered by the very easy to use @bytesoftio/schema package.

import { createForm } from "@bytesoftio/form"
import { object, string } from "@bytesoftio/schema"

const form = createForm({ 
  title: "", 
  user: { firstName: "" }
})
  .schema(object({
    title: string().between(3, 50),
    user: object({ firstName: string().between(3, 50) })
  }))

Create form from schema definition

In order to prevent unnecessary boilerplate code, it is possible to create a form based of a schema definition. It is important to either use the value helper from the @bytesoftio/schema package, or provide a defualt value using the toDefault method.

import { createFormFromSchema } from "@bytesoftio/form"
import { object, value } from "@bytesoftio/schema"

const form1 = createFormFromSchema(object({
  foo: value("default value").string().min(3),
  bar: value(12).number().max(100)
}))
// same as
const form2 = createFormFromSchema(object({
  foo: string().toDefault("default value").min(3),
  bar: number().toDefault(12).max(100)
}))

Validation and errors

A form can be validated manually, before submission, using the validate method. The result is either undefined, in case of a successful validation, or an error object of type ValidationResult, from the @bytesoftio/schema package.

import { createForm } from "@bytesoftio/form"

const form = createForm({})
const errors = await form.validate()

if ( ! errors) {
  // submit form ...
}

Form can be configured to validate only the changed fields, or validate immediately on user input, etc. Take a look at form config section.

You can validate changed fields only without configuring the form, by providing a specific flag.

form.validate({ changedFieldsOnly: true })

When submitting a form, validations are run atomatically, unless configured otherwise.

You can access form errors anytime trough form.errors property. The errors object has many convenience method when dealing with errors.

import { createForm } from "@bytesoftio/form"

const form = createForm()

// get all validation errors
form.errors.get()

// replace old errors with new one
form.errors.set({ firstName: ["Value is missing"] })

// add some new errors, but keep the old ones
form.errors.add({ firstName: ["Value is missing"] })

// are there any errors, returns boolean
form.errors.has()

// clear all errors
form.errors.clear()

// get errors for a specific field
form.errors.getAt("path.to.field")

// replace old errors with the new ones for a specific field
form.errors.setAt("path.to.field", ["New error"])

// add new errors for a specific field, but keep the old ones
form.errors.addAt("path.to.field", "New error")
form.errors.addAt("path.to.field", ["New error1", "New error2"])

// check if there are errors for specific fields
form.errors.hasAt("path.to.field")
form.errors.hasAt(["path.to.field1"], "path.to.field2")

// clear errors for specific fields
form.errors.clearAt("path.to.field")
form.errors.clearAt(["path.to.field1", "path.to.field2"])

Submit form and provide feedback

To submit a form to an API you need to provide a form handler. This piece of logic is responsible for extracting form values, sending data to an endpoint, handling remote errors, providing meaningful feedback to the form consumers / presentation layer.

import { createForm } from "@bytesoftio/form"

const form = createForm()
	.handler(async form => {
		try {
      const result = await sendFormData(form.values.get())
      
     	return  { success: "Data sent succefully", result }
    } catch (error) {
      return { error: "Could not send data" }
    }
  })

To provide meaningful feedback to form consumers, handler can return any kind of data, this data will be available as the result of a submit() call or inside the form.result observable property. Everything can be statically typed, take a look at quick start to learn more about this.

When submitting a form, validations are run automatically, you can prevent this by configuring the form accordingly, see form config, or setting a flag.

const result = await form.submit({ validate: false })

Reset form state

You can reset all of the form state back to its initial value as well as all the errors, dirty fields, results, etc.

import { createForm } from "@bytesoftio/form"

const form = createForm({ field1: "foo", field2: "bar" })

// reset form
form.reset()

// reset form and provide a new initial state
form.reset({ field1: "", field2: ""})

Dirty fields and changed fields

You can check if any specific field has been changed through user input. There is a differentiation between diryFields and changedFields. As soon as a form field has been changed, for example from "" to "foo" - it becomes dirty. If the field changes back to "", it is still dirty. changedFields works slightly different. A field is changed only if the value is actually different from the initial one. So a field can be dirty and not changed at the same time.

dirtyFields and changedFields have both the same interface ObservableFormFields.

import { createForm } from "@bytesoftio/form"

const form = createForm({})

// grab all dirty fields, returns an array of strings[]
form.dirtyFields.get()

// check if on or multiple fields are dirty, returns boolean
form.dirtyFields.has("field")
form.dirtyFields.has(["field1", "path.to.field2"])

// replace old dirty fields with new opnes
form.dirtyFields.set(["field1", "path.to.field2"])

// add additional dirty fields to the existing ones
form.dirtyFields.add("field1")
form.dirtyFields.add(["field1", "path.to.field2"])

// remove some dirty fields
form.dirtyFields.remove("field1")
form.dirtyFields.remove(["field1", "path.to.field2"])

// clear all fields
form.dirtyfields.clear()

// get notified whenever dirty fields change
form.dirtyFields.listen((fields) => console.log(fields))

Once again, the form.changedFields object is absolutely identical, in terms of interface, to the form.dirtyFields one. There is no need for additional docs here.

Status indicators

Sometimes you'll need a flag to tell if form is currently being submitted, or has already been submitted. In this case you can use one of these status objects: form.submitting or form.submitted. Both implement the ObservableValue interface that comes from the @bytesoftio/value package and has all the relevant docs.

// is form currently being submitted, returns boolean
form.submitting.get()
// has form already been submitted, returns boolean
form.submitted.get()

Usage with React

There is a very easy to use, hooks based, React integration for this library. That also comes with bindings for basic HTML input elements. Please take a look at @bytesoftio/use-form

Isolating fields from unnecessary re-renders

In bigger forms you might want to isolate your fields from unnecessary re-renders. This can very easily be achieved using the @bytesoftio/isolate package.

import React, { useState } from "react"
import { createForm } from "@bytsoftio/form"
import { useForm } from "@bytsoftio/use-form"

const Component = () => {
  const form = useForm(() => createForm({ field1: "foo", field2: "bar" }))
  const [someValue, setSomeValue] = useState("some state")

  return (
    <form>
      <Isolate deps={form.deps("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 speicifc to this field, like: `errors`, `values`, `changedFields` or `dirtyFields`. 
      </Isolate>
      
      <Isolate deps={form.deps(["field1", "field2"])}>
        This section will change when one of the two fields receives a relevant change
      </Isolate>

      <Isolate deps={form.deps(["field1", "field2"], { errors: false })}>
        This block will NOT re-render if there is an error for one of the two fields.
      </Isolate>

      <Isolate deps={[...form.deps(["field1", "field2"]), someValue]}>
        Include an aditional, custom, value to the list of dependencies for a re-render.
      </Isolate>
    </form>
  )
}

Readme

Keywords

none

Package Sidebar

Install

npm i @bytesoftio/form

Weekly Downloads

1

Version

3.11.0

License

MIT

Unpacked Size

50 kB

Total Files

30

Last publish

Collaborators

  • maximkott