├── .babelrc
├── .gitignore
├── README.md
├── package-lock.json
├── package.json
├── public
└── index.html
└── src
├── Basic.js
├── Example.js
├── index.js
└── lib
└── index.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [["@babel/preset-react", { "absoluteRuntime": false }]]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | dist
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fractal-form
2 |
3 | An experimental React form library using lenses. Lenses are a concept from functional programming. They are modular data accessors that play nice with immutable data. A big advantage of lens implementation in this library is that they are self-similar, so you can create reusable form components at any level of nesting in your application state.
4 |
5 | This library is heavily inspired by André Staltz's [use-profunctor-state](https://github.com/staltz/use-profunctor-state).
6 |
7 | See `src/Example.js` for an example implementation.
8 |
9 | ## Installation
10 |
11 | ```
12 | npm install fractal-form
13 | ```
14 |
15 | ## Usage
16 |
17 | ### Basic example
18 |
19 | ```jsx
20 | import { memo, useState } from "react"
21 | import { useFractalForm, useLensField } from "fractal-form"
22 |
23 | const Input = memo(({ label, value, onChange }) => (
24 |
27 | ))
28 |
29 | export const Basic = () => {
30 | const [submittedValues, setSubmittedValues] = useState({})
31 | const {useFormLens, value} = useFractalForm({ name: '' })
32 | const [nameField] = useLensField(useFormLens, 'name')
33 |
34 | const submitForm = () => setSubmittedValues(value)
35 |
36 | return (
37 |
38 |
39 |
40 |
{JSON.stringify(submittedValues, null, 2)}
41 |
42 | )
43 | }
44 | ```
45 |
46 | ## API Reference
47 |
48 | #### `useFractalForm`
49 |
50 | ```javascript
51 | const initialValues = { name: 'Bob' }
52 | const {
53 | form, value, error, touched,
54 | setForm, setValues, setErrors, setTouched,
55 | useFormLens, useValuesLens, useErrorsLens, useTouchedLens,
56 | } = useFractalForm(initialValues)
57 | ```
58 |
59 | Create a form object for a given initial state.
60 |
61 | - `form` is the entire form state, an object composed of keys `{ value, error, touched }`.
62 | - `value` contains the form values. Initially set to `initialValues`.
63 | - `error` contains the errors. Initially set to `{}`
64 | - `touched` contains the touched status of the fields. Initally set to `{}`
65 |
66 | - `set*` are functions that set the given object outright. You probably should avoid using those.
67 |
68 | - `use*Lens` are hooks which provide lenses to each one of the properties. They are the same as `useLens` hooks returned from `useLensState` (see below).
69 |
70 |
71 | #### `useLensField`
72 | ```javascript
73 | const validateName = (_parentValue, name) => name.length < 5 ? "Name too short" : null
74 |
75 | const { useFormLens } = useFractalForm({ name: 'Bob' })
76 |
77 | const [nameField, setNameField, useNameFieldLens] = useLensField(useFormLens, 'name', validateName)
78 |
79 | return (
80 |
81 | )
82 | ```
83 |
84 | This hook creates a lens that focuses on a particular field name from the `value`, `touched`, and `error` object.
85 |
86 | - `nameField` is a utility object containing the `value`, `error`, and `touched` values for the given field name as well as `onChange` (updates the value and validates it) and `onBlur` (sets `touched` to true) callbacks.
87 | - `setNameField` expects an object of `{ value, error, touched }` for the given field
88 | - `useNameFieldLens` is a lens for the value, error and touched for the given field. It can be provided as the first argument to another `useLensField`
89 |
90 | #### `useLensState`
91 |
92 | ```javascript
93 | const [state, setState, useLens] = useLensState({ name: 'Bob' })
94 | const [name, setName] = useLens(s => s.name, (s, a) => ({ ...s, name: a }))
95 |
96 | console.log(state) // -> { name: 'Bob' }
97 | console.log(name) // -> 'Bob'
98 |
99 | setName('Alice')
100 |
101 | console.log(name) // -> 'Alice'
102 | console.log(state) // -> { name: 'Alice' }
103 |
104 | setState({ name: 'Charles' })
105 |
106 | console.log(name) // -> 'Charles'
107 | ```
108 |
109 | This hook is exactly the same as React's `useState` except it adds another element to the array. `useLens` is a hook that takes two functions:
110 |
111 | - `view: s => a` takes a full state `s` and returns a partial state `a`
112 | - `update: (s, a) => t` takes a full state `s` and a new partial state `a` and returns a new full state `t`
113 |
114 | The two states are going to be synchronised.
115 |
116 | The lenses are memoized so most of the time wrapping a component which uses them in `React.memo` should prevent unnecessary rerenders.
117 |
118 | #### `lensForProp`
119 |
120 | ```javascript
121 | const [state, setState, useLens] = useLensState({ name: 'Bob' })
122 | const [name, setName] = useLens(...lensForProp('name'))
123 | ```
124 |
125 | A helper which takes a key name returns a pair of functions `[view, update]` for that key name.
126 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fractal-form",
3 | "description": "An experimental React form library using lenses.",
4 | "keywords": [
5 | "react",
6 | "forms",
7 | "lens",
8 | "optics"
9 | ],
10 | "version": "0.1.0",
11 | "main": "dist/index.js",
12 | "module": "dist/index.js",
13 | "files": [
14 | "dist",
15 | "README.md"
16 | ],
17 | "repository": {
18 | "type": "git",
19 | "url": "https://github.com/intercaetera/fractal-form"
20 | },
21 | "scripts": {
22 | "dev": "NODE_ENV=development react-scripts start",
23 | "build": "rm -rf dist && NODE_ENV=production babel src/lib --out-dir dist --copy-files"
24 | },
25 | "eslintConfig": {
26 | "extends": [
27 | "react-app",
28 | "react-app/jest"
29 | ]
30 | },
31 | "devDependencies": {
32 | "@babel/cli": "^7.20.7",
33 | "@babel/preset-react": "^7.18.6",
34 | "react": "^18.2.0",
35 | "react-dom": "^18.2.0",
36 | "react-scripts": "5.0.1"
37 | },
38 | "peerDependencies": {
39 | "react": "^18.2.0",
40 | "react-dom": "^18.2.0"
41 | },
42 | "browserslist": {
43 | "production": [
44 | ">0.2%",
45 | "not dead",
46 | "not op_mini all"
47 | ],
48 | "development": [
49 | "last 1 chrome version",
50 | "last 1 firefox version",
51 | "last 1 safari version"
52 | ]
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | Fractal Form
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/src/Basic.js:
--------------------------------------------------------------------------------
1 | import { memo, useState } from "react"
2 | import { useFractalForm, useLensField } from "./lib"
3 |
4 | const Input = memo(({ label, value, onChange }) => (
5 |
8 | ))
9 |
10 | export const Basic = () => {
11 | const [submittedValues, setSubmittedValues] = useState({})
12 | const {useFormLens, value} = useFractalForm({ name: '' })
13 | const [nameField] = useLensField(useFormLens, 'name')
14 |
15 | const submitForm = () => setSubmittedValues(value)
16 |
17 | return (
18 |
19 |
20 |
21 |
{JSON.stringify(submittedValues, null, 2)}
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/src/Example.js:
--------------------------------------------------------------------------------
1 | import { memo } from 'react'
2 | import { useFractalForm, useLensField } from './lib';
3 |
4 | const initialValues = {
5 | name: 'b',
6 | address: {
7 | street: 'Main Street',
8 | number: 255
9 | }
10 | }
11 |
12 | const Input = memo(({ label, value, error, touched, ...rest }) => (
13 |
17 | ))
18 |
19 | const nameValidate = (_, name) => name.length < 5 ? 'name is too short' : null
20 | const streetValidate = (_, street) => street.includes('Street') ? null : 'street must include the word "Street"'
21 | const numberValidate = (_, number) => isNaN(Number(number)) ? 'number isnt a number' : null
22 |
23 | export const Example = () => {
24 | const {useFormLens, error, value, touched} = useFractalForm(initialValues)
25 |
26 | const [nameField] = useLensField(useFormLens, 'name', nameValidate)
27 | const [_addressField, _setAddress, useAddressLens] = useLensField(useFormLens, 'address')
28 |
29 | return (
30 |
31 |
User
32 |
33 |
34 |
35 |
36 |
37 |
38 |
{JSON.stringify(value, null, 2)}
39 |
40 |
41 |
42 |
{JSON.stringify(error, null, 2)}
43 |
44 |
45 |
46 |
{JSON.stringify(touched, null, 2)}
47 |
48 |
49 | );
50 | }
51 |
52 | const AddressForm = memo(({ useAddressLens }) => {
53 | const [streetField] = useLensField(useAddressLens, 'street', streetValidate)
54 | const [numberField] = useLensField(useAddressLens, 'number', numberValidate)
55 |
56 | return (
57 | <>
58 | Address
59 |
60 |
61 | >
62 | )
63 | })
64 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import {Basic} from './Basic'
4 |
5 | import { Example } from './Example'
6 |
7 | const root = ReactDOM.createRoot(document.getElementById('root'))
8 | root.render(
9 |
10 |
11 |
12 |
13 | )
14 |
--------------------------------------------------------------------------------
/src/lib/index.js:
--------------------------------------------------------------------------------
1 | import { useMemo, useCallback, useState } from "react"
2 |
3 | export const lensForProp = prop => ([whole => whole[prop], (whole, part) => ({ ...whole, [prop]: part })])
4 |
5 | const newLens = (state, setState) => {
6 | const useLens = (view, update) => {
7 | const setInnerState = newPartOrUpdate => {
8 | setState(prevState => {
9 | const prevPart = view(prevState)
10 | const newPart = typeof newPartOrUpdate === 'function' ? newPartOrUpdate(prevPart) : newPartOrUpdate
11 | if (newPart === prevPart) return prevState
12 | return update(prevState, newPart)
13 | })
14 | }
15 |
16 | const innerState = view(state)
17 | const innerLens = useMemoizedLens(innerState, setInnerState)
18 |
19 | return innerLens
20 | }
21 |
22 | return [state, setState, useLens]
23 | }
24 |
25 | const useMemoizedLens = (state, setState) => useMemo(
26 | () => newLens(state, setState),
27 | [state]
28 | )
29 |
30 | export const useLensState = (initialState) => {
31 | const [state, setState] = useState(initialState)
32 | const lens = useMemoizedLens(state, setState)
33 |
34 | return lens
35 | }
36 |
37 | export const useFractalForm = (initialValues) => {
38 | const initialState = { value: initialValues, error: {}, touched: {} }
39 | const [form, setForm, useFormLens] = useLensState(initialState)
40 |
41 | const [value, setValues, useValuesLens] = useFormLens(...lensForProp('value'))
42 | const [error, setErrors, useErrorsLens] = useFormLens(...lensForProp('error'))
43 | const [touched, setTouched, useTouchedLens] = useFormLens(...lensForProp('touched'))
44 |
45 | return {
46 | form, value, error, touched,
47 | setForm, setValues, setErrors, setTouched,
48 | useFormLens, useValuesLens, useErrorsLens, useTouchedLens,
49 | }
50 | }
51 |
52 | export const useLensField = (useLens, fieldName, validate = () => null) => {
53 | const [{value, error, touched}, setField, useFieldLens] = useLens(
54 | form => ({
55 | value: form.value?.[fieldName],
56 | error: form.error?.[fieldName],
57 | touched: form.touched?.[fieldName]
58 | }),
59 | (form, { value, error, touched }) => ({
60 | value: { ...form.value, [fieldName]: value },
61 | error: { ...form.error, [fieldName]: error },
62 | touched: { ...form.touched, [fieldName]: touched },
63 | })
64 | )
65 |
66 | const onChange = useCallback(event => {
67 | setField({ value: event.target.value, error: validate(value, event.target.value), touched })
68 | }, [touched])
69 |
70 | const onBlur = useCallback(() => setField({ value, error, touched: true }), [error, value])
71 |
72 | const field = { onChange, onBlur, value, error, touched }
73 |
74 | return [field, setField, useFieldLens]
75 | }
76 |
77 |
--------------------------------------------------------------------------------