├── .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 | --------------------------------------------------------------------------------