flemme
v0.5.0
Published
Framework agnostic form state manager / handler / abstraction
Maintainers
Readme
Flemme
Dependency-free* framework-agnostic form management
The Bundle Size Badge is the size of the recommended installation, see it in action on bundlejs.com.
* See installation steps.
− “Flemme” means “Laziness” in French.
Table of contents:
- Installation
- Basic usage
- ⚠️ Limitations
- Demos
- Philosophy
- API
- createForm{ initial, submit, validate?, validationTriggers? }
- Form
form.initialValuesform.valuesform.get(path)form.isDirty() & isDirtyAt(path)form.isTouched & isTouchedAt(path?)form.set(values) / form.set(path, value)form.reset(nextInitialValue?)form.resetAt(path, nextInitialValue?)form.blur(path)form.focus(path)form.on(event, listener) / form.on(event, path, listener)form.validate()form.errorsform.isValidsubmit()Form<T>
- Helpers
Installation
npm i -D flemmeTS users: Enabling proper types requires TS v4.1+ and type-fest v0.21+
React users: check out the React binding package flemme-react
Vue users: check out the Vue binding package flemme-vue
Vanilla users: check out the DOM binding package flemme-dom
Basic usage
// src/path/to/user-form.(js|ts)
import { createForm, withSchema, addItem, removeItem /* for arrays */ } from 'flemme'
export const createUserProfileForm = (initialValues) => createForm({
initialValues,
schema: userSchema, // any standard schema is supported: zod's, valibot, unhoax, …
// all available triggers, pick only a subset of course (ideally one only)
// I advise ['change', 'blur']
validationTriggers: ['change', 'blur', 'focus', 'reset'],
submit: async (values) => {
await fetch('…', {})
},
})
const form = createUserProfileForm({
name: { first: 'John', last: 'Doe' },
birthDate: new Date('1968-05-18'),
tags: ['awesome guy', 'great dude'],
})
// mimic actual user actions
form.focus('name.first')
form.set('name.first', 'Fred')
form.blur('name.first')
form.focus('name.last')
form.set('name.last', 'Aster')
form.blur('name.last')
form.focus('tags.1')
form.set('tags.1', 'great dancer') // replaces "great dude" by "great dancer"
form.blur('tags.1')
// Array add/append value
form.set('tags.2', 'Lovely') // since index 2 does not exist, it will be added
form.set('tags', add(form.value('tags'), 'Kind hearted')) // append tag
form.set('tags', add(form.value('tags'), 'Subtle guy', 1)) // add at index 1
// Array remove value
form.set('tags', remove(form.value('tags'), 1)) // remove tag at index 1
form.submit()
.then(() => {…})
.catch(() => {…})⚠️ Limitations
- The top-level value must be an object or an array
- Properties cannot contain a
.because of the path notation.const values = { 'toto.tata': 'Hello !' }will not work. - Only serializable types are supported, which excludes:
- Set
- Map
- Iterables
- symbol
- functions
- etc.
Demos
- With
zodvalidation: demo | source - With
unhoaxvalidation: demo | source - With React: demo | source
- With Vue: demo | source
- With DOM (vanilla): demo | source
Philosophy
Handling forms means two main parts:
- Form state, such as dirty/pristine, touched/modified, visited, active and state mutations
- Form validation
And it should have to be testable in any environment (browser, node, deno, etc.).
About form validation, there already exist wonderful tools to validate schema or even add cross-field validation, the idea is to not reimplement one. Among those tools:
Since TypeScript v4.1, lodash-path related function can be strongly typed, therefore using lodash-like path felt like a commonly known API to propose.
API
createForm<T>({ initialValues, submit, schema?, validationTriggers? })
const createForm: <T>(options: {
initialValues: T // array or object
schema: StandardSchema<T>
validationTriggers: Array<'change' | 'blur' | 'focus' | 'reset' | 'validated'>
submit: (values: T) => Promise<unknown>
}) => Form<T>Form
form.initialValues
interface Initial<T> {
readonly initialValues: T
getInitial<P extends Paths<T>>(path: P): Get<T, P>
}
// Usage:
form.initialValues // form initial value
form.initialValues.user.name.first // initial sub value
form.getInitial('user.name.first') // stringform.values
interface Values<T> {
readonly values: T
}
// Usage:
form.values // form initial value
form.values.user.name.first // initial sub valueform.get(path)
interface Value<T> {
get<P extends Paths<T>>(path: P): Get<T, P> // strongly typed: value will be inferred from path
}
// Usage:
form.get('user.name.first') // string
form.get('user.name') // { first: string, last: string }form.isDirty & isDirtyAt(path)
A field is marked as "dirty" when its value is deeply unequal to its initial value.
// Usage:
form.isDirty // check the whole form
form.isDirtyAt('user.name.first') // check only a sub value
form.isDirtyAt('user.name') // check only a subset of propertiesform.isTouched & isTouchedAt(path)
A field is marked as "touched" when it has gained focus once. Only a form.reset(path?) unmarks the field as "touched".
type IsTouched = (path?: string) => boolean
// Usage:
form.isTouched // check the whole form
form.isTouchedAt('user.name.first') // check only a sub value
form.isTouchedAt('user.name') // check only a subset of propertiesform.set(values) / form.set(path, value)
interface Set<T> {
set(value: T): void
set<P extends Paths<T>>(
path: P,
value: Get<T, P>, // strongly typed: value will be inferred from path
): void
}
// Usage:
// change form value
form.set({
user: {
name: {
first: 'John',
last: 'Doe',
},
},
})
// change sub value
form.set('user.name.first', 'John')
form.set('user.name', {
first: 'John',
last: 'Doe',
})form.reset(nextInitialValue?)
type Reset<T> = (nextInitialValue?: T) => void
// Usage:
// reset to current initial value
form.reset()
// reset to new initial value
form.reset({
user: {
name: {
first: 'John',
last: 'Doe',
},
},
})form.resetAt(path, nextInitialValue?)
type ResetAt<T> = <P extends string>(
path: P,
nextInitialValue?: PartialDeep<Get<T, P>>, // strongly typed: value will be inferred from path
): void
// Usage:
// reset to current initial value
form.resetAt('user.name.first')
form.resetAt('user.name')
// reset to new initial value
form.resetAt('user.name.first', 'John')
form.resetAt('user.name', {
first: 'John',
last: 'Doe',
})form.blur(path)
⚠️ Should be called only for primitive properties like string, number, date or booleans.
type Blur = (path: string) => void
// Usage:
form.blur('user.name.first')
form.blur('user.name.last')form.focus(path)
⚠️ Should be called only for primitive properties like string, number, date or booleans.
type Focus = (path: string) => void
// Usage:
form.focus('user.name.first')
form.focus('user.name.last')form.on(event, listener) / form.on(event, path, listener)
NB: The path is not relevant for 'validated' event
// Usage:
// 'change' examples
const unsubscribe = form.on('change', ({ path, previous, next }) => {
console.log('form value changed', path, previous, next)
})
unsubscribe()
form.on('change', 'user.name', ({ path }) => console.log('form user name changed'))
form.on('change', 'user.name.first', ({ path }) => console.log('form user first name changed'))
// 'blur' examples
form.on('blur', ({ path }) => console.log('A form nested property has been blurred'))
form.on('blur', 'user.name', ({ path }) => console.log('user first or last name has been blurred'))
form.on('blur', 'user.name.first', ({ path }) => console.log('user first name has been blurred'))
// 'validated' examples − the path is not relevant here
form.on('validated', ({ errors }) => console.log('Form has been validated'))
form.on('submit', ({ values }) => {
console.log('submit started')
})
form.on('submitted', ({ values, error }) => {
console.log('is success:', !error)
console.log('submitted values:', values)
})
// returns an `unsubscribe` function
interface On {
<P extends Paths<T>>(
event: 'reset' | 'change',
path: P,
listener: (data: { path: P; previous: Get<T, P>; next: Get<T, P> }) => unknown,
): () => void
(event: 'reset' | 'change', listener: (data: { path: ''; previous: T; next: T }) => unknown): () => void
<P extends Paths<T>>(event: 'focus' | 'blur', path: P, listener: (data: { path: P }) => unknown): () => void
(event: 'focus' | 'blur', listener: (data: { path: Paths<T> }) => unknown): () => void
(event: 'validated', listener: (data: { errors: FormError<T>[] }) => unknown): () => void
(event: 'submit', listener: (data: { values: T }) => unknown): () => void
(event: 'submitted', listener: (data: { values: T; error?: unknown }) => unknown): () => void
}form.validate()
Populates form error with found errors if any.
Emits a 'validated' event.
type Validate = () => void
// Usage:
form.validate()form.errors
type Errors<FormValues> = {
readonly errors: ReadonlyArray<{ message: string; path: Paths<FormValues> }>
}
// Usage:
form.errorsform.isValid
Returns true when form.errors is empty. Basically.
type IsValid = {
readonly isValid: boolean
}
// Usage:
form.validate() // sets the error
if (!form.isValid) {
throw new Error('…')
}submit()
NB: Under the hood, it validates the form − if a validate function was provided −, and executes the handler only if the form is valid.
If the form is valid, it emits the event 'submit' when starting submission, then 'submitted' when done (succeeding or failing).
export type Submit<T> = (handler: (value: T) => Promise<any>) => Promise<void>
// Usage:
import { createForm } from 'flemme'
const form = createForm({
…,
submit: async (values) => {
const response = await fetch('/users', {
method: 'POST',
body: JSON.stringify({
firstName: values.user.name.first,
lastName: values.user.name.last,
}),
})
if (!response.ok) throw new Error('Received an error')
}
})
await form.submit()Form<T>
export type Form<T> = {
// readers
readonly initialValues: T
readonly values: T
readonly errors: ReadonlyArray<{ message: string; path: Paths<T> }>
readonly isValid: boolean
readonly isDirty: boolean
readonly isTouched: boolean
get<P extends Paths<T>>(path: P): Get<T, P>
isDirtyAt(path: Paths<T>): boolean
isTouchedAt(path: Paths<T>): boolean
// actions/operations
set: {
(value: T): void
<P extends Paths<T>>(path: P, value: Get<T, P> | undefined): void
}
reset: (nextInitialValue?: T) => void
resetAt: <P extends Paths<T>>(path: P, nextInitialValue?: Get<T, P & string>) => void
blur: (path: Paths<T>) => void
focus: (path: Paths<T>) => void
submit: () => Promise<unknown>
validate: () => void
// events
on: {
<P extends Paths<T>>(event: 'reset' | 'change', path: P, listener: ChangeListener<T, P>): () => void
(event: 'reset' | 'change', listener: ChangeListener<T>): () => void
<P extends Paths<T>>(event: 'focus' | 'blur', path: P, listener: FocusListener<T, P>): () => void
(event: 'focus' | 'blur', listener: FocusListener<T>): () => void
(event: 'validated', listener: () => void): () => void
(event: 'submit', listener: (data: { values: T }) => unknown): () => void
(event: 'submitted', listener: (data: { values: T; error?: unknown }) => unknown): () => void
}
}Helpers
NB: The lib is tree-shakeable. Therefore if you don’t use any of these, they won’t jump into your bundle 🪶
addItem(array, value, atIndex?)
import { addItem } from 'flemme'
const myArray = ['a', 'b', 'c', 'd']
const myNewArray1 = addItem(myArray, 'e') // append 'e'
const myNewArray2 = addItem(myArray, 'e', 2) // ['a', 'b', 'e', 'c', 'd']removeItem(array, index)
import { removeItem } from 'flemme'
const myArray = ['a', 'b', 'c', 'd']
const myNewArray1 = removeItem(myArray, 2) // removes 'c' → ['a', 'b', 'd']
const myNewArray2 = removeItem(myArray, 123) // removes nothing
const myNewArray3 = removeItem(myArray, -1) // removes nothing