flemme
Version:
Framework agnostic form state manager / handler / abstraction
648 lines (491 loc) • 19.2 kB
Markdown
<p align="center">
<img alt="Flemme Logo" src="https://github.com/SacDeNoeuds/flemme/blob/main/public/logo-200.png">
</p>
<p align="center">
<a href="https://badge.fury.io/js/flemme">
<img alt="npm version" src="https://badge.fury.io/js/flemme.svg">
</a>
<a href="https://www.npmjs.com/package/flemme">
<img alt="Downloads" src="https://img.shields.io/npm/dm/flemme.svg">
</a>
<a href="http://standardjs.com" target="_blank">
<img alt="js-standard-style" src="https://img.shields.io/badge/code%20style-standard-brightgreen.svg">
</a>
<img alt="bundle size" src="https://deno.bundlejs.com/badge?q=object-deep-copy,just-safe-get,just-safe-set,fast-deep-equal,flemme&treeshake=[{+default+as+objectDeepCopyDefault+}],[{+default+as+get+}],[{+default+as+set+}],[{+default+as+fastDeepEqual+}],[{+Flemme+}]">
<img alt="Total coverage" src="https://raw.githubusercontent.com/SacDeNoeuds/flemme/refs/heads/main/modules/flemme/badges/coverage-total.svg">
<img alt="Dependency Count" src="https://badgen.net/bundlephobia/dependency-count/flemme">
</p>
<!-- [](https://www.buymeacoffee.com/SacDeNoeuds) -->
Dependency-free\* framework-agnostic form management
The Bundle Size Badge is the size of the recommended installation, see it in action on <a href="https://bundlejs.com/?q=object-deep-copy%2Cjust-safe-get%2Cjust-safe-set%2Cfast-deep-equal%2Cflemme&treeshake=%5B%7B+default+as+objectDeepCopyDefault+%7D%5D%2C%5B%7B+default+as+get+%7D%5D%2C%5B%7B+default+as+set+%7D%5D%2C%5B%7B+default+as+fastDeepEqual+%7D%5D%2C%5B%7B+Flemme+%7D%5D">bundlejs.com</a>.
<small>\* See installation steps.</small>
− “Flemme” means “Laziness” in French.
---
Table of contents:
- [Installation](
- [Basic usage](
- [Limitations](
- [Demos](
- [Philosophy](
- [API](
- [`Flemme({ get, set, cloneDeep, isEqual })`](
- [`createForm<T, ValidationErrors>`](
- [Form](
- [`form.initialValues`](
- [`form.values`](
- [`form.get(path)`](
- [`form.isDirty` & `isDirtyAt(path)`](
- [`form.isVisited(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.errors`](
- [`form.isValid`](
- [`submit()`](
- [`Form<T, ValidationErrors>`](
- [Helpers](
- [`addItem(array, value, atIndex?)`](
- [`removeItem(array, index)`](
```sh
npm i -D flemme
```
Then create a file to initialise the lib. Since I don’t want to enforce lib choices but I still need classic functions, you’ll have to inject into the lib:
```ts
// src/lib/flemme.(js|ts)
import { Flemme } from 'flemme'
import { get, set, isEqual, deepClone } from 'your-favorite-tool'
export const createForm = Flemme({ get, set, isEqual, cloneDeep: deepClone })
```
<details>
<summary>Advised when you don’t have libraries like lodash/underscore/mout</summary>
```ts
// src/lib/flemme.ts
import { Flemme } from 'flemme'
import fastDeepEqual from 'fast-deep-equal' // 852B minified
import objectDeepCopy from 'object-deep-copy' // 546B minified
import get from 'get-value' // 1.2kB minified
import set from 'set-value' // 1.5kB minified
// OR
import get from '@strikeentco/get' // 450B minified
import set from '@strikeentco/set' // 574B minified
// OR
import get from 'just-safe-get' // recommended
import set from 'just-safe-set' // recommended
export const createForm = Flemme({
get,
set,
isEqual: fastDeepEqual,
cloneDeep: objectDeepCopy,
})
```
</details>
<details>
<summary>With Lodash</summary>
```ts
// src/lib/flemme.ts
import { Flemme } from 'flemme'
import _ from 'lodash-es' // or 'lodash'
export const createForm = Flemme({
get: _.get,
set: _.set,
isEqual: _.isEqual,
cloneDeep: _.cloneDeep,
})
```
</details>
<details>
<summary>With MoutJS</summary>
```ts
// src/lib/form.(ts|js)
import { Flemme } from 'flemme'
import { get, set, deepClone } from 'mout/object'
import { deepEquals, deepClone } from 'mout/lang'
export const createForm = Flemme({
get,
set,
isEqual: deepEquals,
cloneDeep: deepClone,
})
```
</details>
<details>
<summary>Underscore JS</summary>
⚠️ **Untested!**
```ts
import { Flemme } from 'flemme'
import _ from 'underscore'
import deepCloneMixin from 'underscore.deepclone'
import getSetMixin from 'underscore.getset'
_.mixin(deepCloneMixin)
_.mixin(getSetMixin)
const createForm = Flemme({
get: _.get,
set: _.set,
isEqual: _.isEqual,
cloneDeep: _.deepClone,
})
```
</details>
**TS users**: Enabling proper types requires TS v4.1+ and type-fest v0.21+
**React users**: check out the React binding package [`flemme-react`](https://github.com/SacDeNoeuds/flemme/tree/main/bindings/react)
```ts
// src/path/to/user-form.(js|ts)
import { addItem, removeItem /* for arrays */ } from 'flemme'
import { createForm } from 'path/to/lib/form'
export const makeUserProfileForm = (initialValue) => createForm({
initial: initialValue,
submit: async (values) => { await fetch('…', {}) },
validate: validateUserProfileForm,
validationTriggers: ['change', 'blur', 'focus', 'reset', 'validated'], // all available triggers, pick only a subset of course (ideally one only)
})
const validateUserProfileForm = (value) => {
// NOTE: not necessarily an array, the data type of your choice
// who am I to tell you what data type best suits your need ?
const errors = []
if (!value.name) errors.push({ code: 'name is required' })
return errors.length === 0
? undefined // NOTE: that's how the lib knows the form is valid
: errors
}
const form = makeUserProfileForm({
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(() => {…})
```
:warning: The top-level value _must_ be an object or an array
- With `superstruct` validation: [demo](https://sacdenoeuds.github.io/flemme/with-superstruct/) | [source](https://github.com/SacDeNoeuds/flemme/blob/main/with-superstruct)
- With `yup` validation: [demo](https://sacdenoeuds.github.io/flemme/with-yup/) | [source](https://github.com/SacDeNoeuds/flemme/blob/main/with-yup)
- With React: [demo](https://sacdenoeuds.github.io/flemme/with-react/) | [source](https://github.com/SacDeNoeuds/flemme/blob/main/with-react)
I think handling forms means two main parts:
1. Form **state**, such as dirty/pristine, touched/modified, visited, active and state mutations
2. 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:
- [unhoax](https://github.com/SacDeNoeuds/unhoax)
- [zod](https://github.com/colinhacks/zod)
- [superstruct](https://docs.superstructjs.org/)
- [io-ts](https://gcanti.github.io/io-ts/)
- [jsonschema](https://github.com/tdegrunt/jsonschema) − validates [JSON Schema declarations](http://json-schema.org/)
- …
About form **state**, I figured that in every project at some point we use a utility library like lodash/underscore, therefore functions like `get`, `set` and `isEqual` are _already_ available. This library takes advantage of that and focuses on form state _only_ ; You bring your own validators − and I advise you use a tool mentioned above :innocent:
Plus since TypeScript v4.1, lodash-path related function can be typed strongly, so using lodash-like path felt like a commonly known API to propose.
Now you ought to know (if you don’t yet): a great framework-agnostic form library already exists: [final-form](https://final-form.org/). However, I find the API and config not to be _that_ straightforward. FYI, it’s 16.9kB and has a separate package for arrays while this one is 1.82KB … not counting that you have to bring your own set/get/isEqual functions ; but as mentioned above, you usually already have them in your project. Another advantage of final-form is its very [complete ecosystem](https://final-form.org/docs/final-form/companion-libraries).
```ts
const Flemme: (parameters: {
get: (target: any, path: string, defaultValue?: any) => any
set: (target: any, path: string, value: any) => void
isEqual: (a: any, b: any) => boolean
cloneDeep: <T>(value: T) => T
}) => MakeForm
```
```ts
const createForm: <T, ValidationErrors>(options: {
initial: PartialDeep<T> // array or object
validate: (value: PartialDeep<T>) => ValidationErrors | undefined
validationTriggers: Array<'change' | 'blur' | 'focus' | 'reset' | 'validated'>
submit: (values: T) => Promise<unknown>
}) => Form<T, ValidationErrors>
```
**:warning: NB: You bring your own validation errors shape, the only requirement is that `undefined` is returned when no error**
```ts
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') // string
```
#### `form.values`
```ts
interface Values<T> {
readonly values: T
}
// Usage:
form.values // form initial value
form.values.user.name.first // initial sub value
```
```ts
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 property is marked as dirty when its value is deeply unequal to its initial value.
```ts
// 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 properties
```
#### `form.isVisited(path?)`
A property is marked as visited when it has gained focus once. Only a `form.reset(path?)` unmarks the poperty as "visited".
```ts
type IsVisited = (path?: string) => boolean
// Usage:
form.isVisited() // check the whole form
form.isVisited('user.name.first') // check only a sub value
form.isVisited('user.name') // check only a subset of properties
```
```ts
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?)`
```ts
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',
},
},
})
```
```ts
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',
})
```
⚠️ Should be called only for primitive properties like string, number, date or booleans.
```ts
type Blur = (path: string) => void
// Usage:
form.blur('user.name.first')
form.blur('user.name.last')
```
⚠️ Should be called only for primitive properties like string, number, date or booleans.
```ts
type Focus = (path: string) => void
// Usage:
form.focus('user.name.first')
form.focus('user.name.last')
```
**NB:** The `path` is not relevant for `'validated'` event
```ts
// 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: ValidationErrors | undefined }) => unknown): () => void
(event: 'submit', listener: (data: { values: T }) => unknown): () => void
(event: 'submitted', listener: (data: { values: T; error?: unknown }) => unknown): () => void
}
```
Populates form error with − your − `ValidationErrors` or `undefined`
Emits a `'validated'` event.
```ts
type Validate = () => void
// Usage:
form.validate()
```
```ts
type Errors<ValidationErrors> = {
readonly errors: ValidationErrors | undefined
}
// Usage:
form.errors // your error value or `undefined`
```
Returns `true` when `form.errors` is `undefined`. Basically.
```ts
type IsValid = {
readonly isValid: boolean
}
// Usage:
form.validate() // sets the error
if (!form.isValid) {
throw new Error('…')
}
```
**NB:** Under the hood, it validates the form − if a `validate` function was provided −, and executes the handler **only** if the form is valid.
Emits events `'submit'` when starting submission, and `'submitted'` when done (succeeding or failing).
```ts
export type Submit<T> = (handler: (value: T) => Promise<any>) => Promise<void>
// Usage:
import { createForm } from '<repo>/library/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()
```
```ts
export type Form<T, ValidationErrors> = {
// readers
readonly initialValues: T
readonly values: T
readonly errors: ValidationErrors | undefined
readonly isValid: boolean
readonly isDirty: boolean
get<P extends Paths<T>>(path: P): Get<T, P & string>
isDirtyAt(path: Paths<T>): boolean
isVisited(path?: Paths<T>): boolean
// actions/operations
set: {
(value: T): void
<P extends Paths<T>>(path: P, value: Get<T, P & string> | 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
// 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: ValidatedListener<ValidationErrors>): () => void
(event: 'submit', listener: (data: { values: T }) => unknown): () => void
(event: 'submitted', listener: (data: { values: T; error?: unknown }) => unknown): () => void
}
// form actions/operations:
/** Submit:
* 1. Set all paths as modified & visited
* 2. Reset after success
* 3. Restore user action performed while submitting if some
*/
submit: () => Promise<unknown>
validate: () => void
}
```
**NB**: The lib is tree-shakeable. Therefore if you don’t use any of these, they won’t jump into your bundle :wink:
```ts
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']
```
```ts
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
```