Gathering detailed insights and metrics for dendriform-immer-patch-optimiser
Gathering detailed insights and metrics for dendriform-immer-patch-optimiser
Gathering detailed insights and metrics for dendriform-immer-patch-optimiser
Gathering detailed insights and metrics for dendriform-immer-patch-optimiser
Build performant, reactive data-editing UIs for React.js. Succinct code, observable state, undo & redo included!
npm install dendriform-immer-patch-optimiser
Fix to prevent multiple sibling .render() calls from same form from sharing React keys
Published on 18 Mar 2022
Fix to disable auto-keying / patch optimisations for arrays containing primitives
Published on 17 Mar 2022
Add historySync()
Published on 01 Mar 2022
Make branchAll() and renderAll() on non-iterable types consistent with branch() and render()
Published on 28 Feb 2022
Fix .onDerive value to respect branching
Published on 27 Feb 2022
Add .readonly()
Published on 27 Feb 2022
Module System
Min. Node Version
Typescript Support
Node Version
NPM Version
8 Stars
328 Commits
3 Forks
1 Watching
7 Branches
1 Contributors
Updated on 21 Sept 2024
TypeScript (98.85%)
JavaScript (1.15%)
Cumulative downloads
Total Downloads
Last day
7.2%
18,942
Compared to previous day
Last week
6.8%
98,668
Compared to previous week
Last month
4.5%
407,185
Compared to previous month
Last year
107.5%
4,302,675
Compared to previous year
Build performant, reactive data-editing UIs for React.js. Succinct code, observable state, undo & redo included!
1import React, {useCallback} from 'react'; 2import {useDendriform, useInput} from 'dendriform'; 3 4function MyComponent(props) { 5 6 // create a dendriform with initial state 7 const form = useDendriform(() => ({ 8 name: 'Wappy', 9 address: { 10 street: 'Pump St' 11 }, 12 pets: [ 13 {name: 'Spike'}, 14 {name: 'Spoke'} 15 ] 16 }); 17 18 // subscribe to form value changes 19 form.useChange((value) => { 20 console.log('form changed:', value); 21 }); 22 23 // make callback to add a pet using .set() and immer drafts 24 const addPet = useCallback(() => { 25 form.branch('pets').set(draft => { 26 draft.push({name: 'new pet'}); 27 }); 28 }, []); 29 30 // render the form elements 31 // - form.render() and form.renderAll() create optimised child components 32 // that only update when their form value changes. 33 // - form.renderAll() automatically adds React keys to each element 34 // - useInput() is a React hook that binds a form value to an input 35 // they are safe to use like this because they are always rendered 36 // these each add a 150ms debounce 37 38 return <div> 39 {form.render('name', nameForm => ( 40 <label>name <input {...useInput(nameForm, 150)} /></label> 41 ))} 42 43 {form.render(['address', 'street'], streetForm => ( 44 <label>street <input {...useInput(streetForm, 150)} /></label> 45 ))} 46 47 <fieldset> 48 <legend>pets</legend> 49 <ul> 50 {form.renderAll('pets', petForm => <li> 51 {petForm.render('name', nameForm => ( 52 <label>name <input {...useInput(nameForm, 150)} /></label> 53 ))} 54 </li>)} 55 </ul> 56 <button onClick={addPet}>Add pet</button> 57 </fieldset> 58 </div>; 59};
Dendriform is kind of like a very advanced useState()
hook that:
useState()
typically does
It's not a traditional "web form" library that has fields, validation and a submit mechanism all ready to go, although you can certainly build that with dendriform (hint: see plugins). If you want a traditional web form for React then formik will likely suit your needs better. Dendriform is less specific and far more adaptable, to be used to make entire UIs where allowing the user to edit data is the goal. You get full control over the behaviour of the interface you create, with many of the common problems already solved, and none of the boilerplate.
In many ways it is similar to something like mobx-keystone, which provides a state tree whose parts are reactive and observable.
1yarn add dendriform 2// or 3npm install --save dendriform
Plugins
Advanced usage
Create a new dendriform form using new Dendriform()
, or by using the useDendriform()
hook if you're inside a React component's render method. Pass it the initial value to put in the form, or a function that returns your initial value.
The useDendriform()
hook on its own will never cause a stateful update to the component it's in; the hook just returns an unchanging reference to a Dendriform instance.
1import {Dendriform, useDendriform} from 'dendriform'; 2// ... 3 4const form = new Dendriform({name: 'Bill'}); 5// ... 6 7function MyComponent(props) { 8 const form = useDendriform({name: 'Ben'}); 9 // ... 10}
If you're using Typescript you can pass type information in here.
1type FormValue = { 2 name?: string; 3}; 4 5const form = new Dendriform<FormValue>({name: 'Bill'}); 6// ... 7 8function MyComponent(props) { 9 const form = useDendriform<FormValue>({name: 'Ben'}); 10 // ... 11}
The value can be of any type, however only plain objects, arrays, ES6 classes and ES6 maps will be able to use branching to access and modify child values.
Access your form's value using .value
, or by using the .useValue()
hook if you're inside a React component's render method. The .useValue()
hook will cause a component to update whenever the value changes. Using the hook essentially allows your components to "opt in" to respond to specific value changes, which means that unnecessary component updates can be easily avoided, and is a large part of what makes Dendriform so performant.
1const form = new Dendriform({name: 'Bill'}); 2const value = form.value; 3// value is {name: 'Bill'} 4// ... 5 6function MyComponent(props) { 7 const form = useDendriform({name: 'Ben'}); 8 const value = form.useValue(); 9 // value is {name: 'Ben'} 10 // ... 11}
You can instantiate forms outside of React, and access them and change them inside React components - they work in just the same way.
The only difference is that the lifespan of forms instantiated inside React components will be tied to the lifespan of the component instances they appear in.
1const persistentForm = new Dendriform({name: 'Bill'});
2
3function MyComponent(props) {
4 const value = persistentForm.useValue();
5 // value is {name: 'Bill'}
6 // ...
7}
Accessing values in callbacks is very easy compared to using vanilla React hooks; simply call form.value
in your callback. As form
is an unchanging reference to a form, you do not have to add extra dependencies to your useCallback()
hook, and form.value
will always return the current value, not a stale one.
1function MyComponent(props) { 2 const form = useDendriform({name: 'Ben'}); 3 4 const handleNameAlert = useCallback(() => { 5 alert(form.value); 6 }, []); 7 8 return <button onClick={handleNameAlert}> 9 Alert my name 10 </button>; 11}
Use .branch()
to deeply access parts of your form's value. This returns another form, containing just the deep value.
1const form = new Dendriform({name: 'Bill'}); 2 3const nameForm = form.branch('name'); 4const value = nameForm.value; 5// value is 'Bill' 6// ... 7 8function MyComponent(props) { 9 const form = useDendriform({name: 'Ben'}); 10 11 const nameForm = form.branch('name'); 12 const value = nameForm.useValue(); 13 // value is 'Ben' 14 // ... 15}
You can check if a form is branchable using .branchable
. On a form containing a non-branchable value such as a string, number, undefined or null it will return false, or if the form is branchable it will return true.
1new Dendriform(123).branchable; // returns false 2new Dendriform({name: 'Bill'}).branchable; // returns true
You can still call .branch()
on non-branchable forms - the returned form will be read-only and contain a value of undefined. While this may seem overly loose, it is to prevent the proliferation of safe-guarding code in userland, and is useful for situations where React components that render branched forms are still briefly mounted after a parent values changes from a branchable type to a non-branchable type.
The .branchAll()
methods can be used to branch all children at once, returning an array of branched forms.
1const form = new Dendriform(['a','b','c']); 2 3const elementForms = form.branchAll(); 4// elementForms.length is 3 5// elementForms[0].value is 'a'
You can still call .branchAll()
on non-branchable or non-iterable forms - it will return an empty array in this case.
The .render()
function allows you to branch off and render a deep value in a React component.
The .render()
function's callback is rendered as its own component instance, so you are allowed to use hooks in it. It's optimised for performance and by default it only ever updates if a form value is being accessed with a .useValue()
hook and the deep value changes; or it contains some changing state of its own. This keeps component updates to a minimum.
This act of 'opting-in' to reacting to data changes is similar to mobx, and is in contrast to React's default behaviour which is to make the developer 'opt-out' of component updates by using React.memo
.
Sometimes using useState()
you might need to raise the useState()
hook further up the component heirarchy so that something higher up can set state. But this can then cause much larger parts of the React component heirarchy to be updated in response to those state changes, depending on how much use of React.memo
you have.
With Dendriform, your state can be moved as high up the component heirarchy as you need, or even outside the component heirarchy, without accidentally causing larger parts of the React component heirarchy to update. And unlike redux, you don't have to also put your state into a centralised data store if it doesn't belong there.
1function MyComponent(props) { 2 // this component will never need to update, because 'form' 3 // is an unchanging reference to a dendriform instance 4 5 const form = useDendriform({name: 'Ben'}); 6 7 return <div> 8 {form.render('name', nameForm => { 9 // this component will update whenever 'name' changes, 10 // and only because the useValue() hook is used. 11 // the useValue() hook tells this component 12 // to opt-in to 'name' changes 13 14 const name = nameForm.useValue(); 15 return <div>My name is {name}</div>; 16 })} 17 </div>; 18}
The .render()
function can also be called without branching.
1function MyComponent(props) { 2 const form = useDendriform({name: 'Ben'}); 3 4 return <div> 5 {form.render(form => { 6 // the 'form' passed into this component is the same 7 // as the 'form' belonging to the parent component 8 9 const user = form.useValue(); 10 return <div>My name is {user.name}</div>; 11 })} 12 </div>; 13}
The form
passed into the render()
callback is provided just for convenience of writing less code while branching and rendering. The following examples are equivalent. You can use whichever suits:
1form.render('name', nameForm => { 2 const name = nameForm.useValue(); 3 return <div>My name is {name}</div>; 4}); 5 6form.render(form => { 7 const name = form.branch('name').useValue(); 8 return <div>My name is {name}</div>; 9}); 10 11form.render('name', nameForm => <div>My name is {nameForm.useValue()}</div>); 12 13form.render(() => { 14 const name = form.branch('name').useValue(); 15 16 // ^ this is essentially the same as example 2. 17 // the 'form' being accessed is the form in the parent component 18 // which is fine do to because forms are always 19 // unchanging references that never go stale 20 21 return <div>My name is {name}</div>; 22}); 23
As such, you can access updates to multiple forms in a single render()
component.
1function MyComponent(props) { 2 const tableSize = useDendriform({ 3 height: 100, 4 width: 200, 5 depth: 300 6 }); 7 8 return <div> 9 {tableSize.render(tableSize => { 10 const width = tableSize.branch('width').useValue(); 11 const depth = tableSize.branch('depth').useValue(); 12 return <div> 13 table top area: {width * depth} 14 </div>; 15 })} 16 </div>; 17}
As the callback of .render()
doesn't update in response to changes in the parent's props by default, you may sometimes need to force it to update using the last argument dependencies
.
1function MyComponent(props) { 2 const form = useDendriform({name: 'Ben'}); 3 const [className] = useState('darkMode'); 4 5 return <div> 6 {form.render('name', nameForm => { 7 const name = nameForm.useValue(); 8 return <div className={className}>My name is {name}</div>; 9 }, [className])} 10 </div>; 11}
You can still call .render()
on non-branchable forms - the returned form will be read-only and contain a value of undefined. While this may seem overly loose, it is to prevent the proliferation of safe-guarding code in userland, and is useful for situations where React components that render branched forms are still briefly mounted after a parent values changes from a branchable type to a non-branchable type.
The .renderAll()
function works in the same way as .render()
, but repeats for all elements in an array. React keying is taken care of for you.
See Array operations for convenient ways to let the user manipulate arrays of items.
1function MyComponent(props) {
2 const form = useDendriform({
3 colours: [
4 {colour: 'Red'},
5 {colour: 'Green'},
6 {colour: 'Blue'}
7 ]
8 });
9
10 return <div>
11 {form.renderAll('colours', colourForm => {
12 const colour = colourForm.branch('colour').useValue();
13 return <div>Colour: {colour}</div>;
14 })}
15 </div>;
16}
Please note that if you are rendering an array that contains any primitive values (undefined, number, string) then the automatic keying will not be able to be as accurate as if you were to render an array of objects / arrays / class instances / sets / maps. This is because object references are used to track the movement of array elements through time, and primitive elements cannot be tracked in this way.
Array element forms can also opt-in to updates regarding their indexes using the .useIndex()
hook.
If you'll be allowing users to re-order items in an array, then please note that you'll get better performance if array element components don't know about their indexes. If the .useIndex()
hook is used, a element that has moved its position inside of its parent array will need to update, even if it is otherwise unchanged.
The .index
property is available for usages outside of React.
1function MyComponent(props) { 2 const form = useDendriform({ 3 colours: ['Red', 'Green', 'Blue'] 4 }); 5 6 return <div> 7 {form.renderAll('colours', colourForm => { 8 const colour = colourForm.useValue(); 9 const index = colourForm.useIndex(); 10 11 return <div>Colour: {colour}, index: {index}</div>; 12 })} 13 </div>; 14}
Branch and render functions can all accept arrays of properties to dive deeply into data structures.
1const form = new Dendriform({ 2 pets: [ 3 {name: 'Spike'} 4 ] 5}); 6 7const petName = form.branch(['pets', 0, 'name']); 8// petName.value is 'Spike'
Like with .render()
, the .renderAll()
function can also additionally accept an array of dependencies that will cause it to update in response to prop changes.
You can still call .renderAll()
on non-branchable or non-iterable forms - it will return an empty array in this case.
You can set data directly using .set()
. This accepts the new value for the form. When called, changes will immediately be applied to the data in the form and any relevant .useValue()
hooks and .render()
methods will be scheduled to update by React.
1const form = new Dendriform('Foo'); 2form.set('Bar'); 3// form.value will update to become 'Bar'
The usage is the same in a React component
1function MyComponent(props) { 2 const form = useDendriform('Foo'); 3 4 const name = form.useValue(); 5 6 const setToBar = useCallback(() => form.set('Bar'), []); 7 8 return <div> 9 Current name: {name} 10 11 <button onClick={setToBar}>Set to Bar</button> 12 </div>; 13}
When .set()
is called on a deep form, the deep value will be updated immutably within its parent data shape. It uses structural sharing, so other parts of the data shape that haven't changed will not be affected.
1function MyComponent(props) { 2 const form = useDendriform({name: 'Ben', age: 30}); 3 4 return <div> 5 {form.render('name', nameForm => { 6 const name = nameForm.useValue(); 7 const setToBill = useCallback(() => { 8 nameForm.set('Bill'); 9 }, []); 10 11 return <div> 12 My name is {name} 13 <button onClick={setToBill}>Set to Bill</button> 14 </div>; 15 })} 16 </div>; 17 18 // clicking 'Set to Bill' will cause the form to update 19 // and form.value will become {name: 'Bill', age: 30} 20}
The .set()
function can also accept an Immer producer.
1function MyComponent(props) { 2 const form = useDendriform({count: 0}); 3 4 const countUp = useCallback(() => { 5 form.set(draft => { 6 draft.count++; 7 }); 8 }, []); 9 10 return <div> 11 {form.render('count', countForm => { 12 const count = countForm.useValue(); 13 return <div>Count: {count}</div>; 14 })} 15 16 <button onClick={countUp}>Count up</button> 17 </div>; 18}
The .set()
function can also accept an options object as the second argument which can affect how the set is executed.
The .set()
action can be debounced by passing a number of milliseconds.
1function MyComponent(props) { 2 const form = useDendriform(0); 3 4 const countUpDebounced = useCallback(() => { 5 form.set(count => count + 1, {debounce: 100}); 6 }, []); 7 8 return <div> 9 {form.render(form => { 10 const count = form.useValue(); 11 return <div>Count: {count}</div>; 12 })} 13 14 <button onClick={countUpDebounced}>Count up</button> 15 </div>; 16}
To call it multiple times in a row, use buffer()
to begin buffering changes and done()
to apply the changes. These will affect the entire form including all branches, so form.buffer()
has the same effect as form.branch('example').buffer()
.
1const form = new Dendriform(0); 2form.buffer(); 3form.set(draft => draft + 1); 4form.set(draft => draft + 1); 5form.set(draft => draft + 1); 6form.done(); 7// form.value will update to become 3
You may want to allow subscribers to a form, while also preventing them from making any changes. For this use case the readonly()
method returns a version of the form that cannot be set and cannot navigate history. Calls to set()
, setParent()
, undo()
, redo()
and go()
will throw an error. Any forms branched off a readonly form will also be read-only.
1const form = new Dendriform(0); 2const readonlyForm = form.readonly(); 3 4// readonlyForm can have its .value and .useValue read 5// can subscribe to changes with .onChange() etc. and can render, 6// but calling .set(), .go() or any derivatives 7// will cause an error to be thrown 8 9readonlyForm.set(1); // throws error 10
The useDendriform
hook can automatically update when props change. If a dependencies
array is passed as an option, the dependencies are checked using Object.is()
equality to determine if the form should update. If an update is required, the value
function is called again and the form is set to the result.
Do not add any values from other dendriform forms as a dependency in this manner. Instead use onDerive
and derive one form's changes into the other.
1function MyComponent(props) { 2 const {nameFromProps} = props; 3 4 const form = useDendriform( 5 () => ({ 6 name: nameFromProps 7 }), 8 { 9 dependencies: [nameFromProps] 10 } 11 ); 12 13 return <div> 14 {form.render('name', nameForm => { 15 const name = nameForm.useValue(); 16 return <div>Name: {name}</div>; 17 })} 18 </div>; 19}
If history is also used at the same time, changes in props will replace the current history item rather than create a new one, however is it unlikely that both history and changes in props are required in the same form because history indicates that the form is intended to be the master of its own state, and dependencies indicate that the form is intended to be a slave to props.
For more fine grained control, you may write your own code to handle changes based on props.
1function MyComponent(props) { 2 const {nameFromProps} = props; 3 4 const form = useDendriform({name: nameFromProps}); 5 6 const lastNameFromProps = useRef(nameFromProps); 7 if(!Object.is(lastNameFromProps, nameFromProps)) { 8 form.set({name: nameFromProps}); 9 } 10 lastNameFromProps.current = nameFromProps; 11 12 return <div> 13 {form.render('name', nameForm => { 14 const name = nameForm.useValue(); 15 return <div>Name: {name}</div>; 16 })} 17 </div>; 18}
ES6 classes can be stored in a form and its properties can be accessed using branch methods.
1class Person { 2 firstName = ''; 3 lastName = ''; 4} 5 6const person = new Person(); 7person.firstName = 'Billy'; 8person.lastName = 'Thump'; 9 10const form = new Dendriform(person); 11 12// form.branch('firstName').value will be 'Billy'
But by default you will not be able to modify this value.
1const form = new Dendriform(person); 2form.branch('firstName').set('Janet'); 3// ^ throws an error
To modify a class property, your class must have the immerable
property on it as immer's documentation describes.
You should import immerable
from dendriform
so you are guaranteed to get the immerable symbol from the version of immer that dendriform uses.
1import {immerable} from 'dendriform'; 2 3class Person { 4 firstName = ''; 5 lastName = ''; 6 [immerable] = true; // makes the class immerable 7} 8 9const person = new Person(); 10person.firstName = 'Billy'; 11person.lastName = 'Thump'; 12 13const form = new Dendriform(person); 14form.branch('firstName').set('Janet');
ES6 maps can be stored in a form and its properties can be accessed using branch methods.
1const usersById = new Map(); 2usersById.set(123, 'Harry'); 3usersById.set(456, 'Larry'); 4 5const form = new Dendriform(usersById); 6 7// form.branch(123).value will be 'Harry'
But by default you will not be able to modify this value.
1const form = new Dendriform(usersById); 2form.branch(456).set('Janet'); 3// ^ throws an error
To modify a Map
s value, support must be explicitly enabled by calling enableMapSet()
as immer's documentation describes.
1import {enableMapSet} from 'immer'; 2 3enableMapSet(); 4 5const usersById = new Map(); 6usersById.set(123, 'Harry'); 7usersById.set(456, 'Larry'); 8 9const form = new Dendriform(usersById); 10form.branch(456).set('Janet');
ES6 sets can be stored in a form and its properties can be accessed using branch methods.
1const form = new Dendriform(new Set([1,2,3])); 2 3// form.branch(1).value will be 1 4// form.branch(3).value will be 3
You can only modify a set's value from the parent form, not from a branched form.
You must also explicitly enabled ES6 Set support by calling enableMapSet()
as immer's documentation describes.
1import {enableMapSet} from 'immer'; 2 3enableMapSet(); 4 5const form = new Dendriform(new Set([1,2,3])); 6 7// works 8form.set(draft => { 9 draft.add(4); 10});
You can easily bind parts of your data to form inputs using useInput()
and useCheckbox()
. The props they return can be spread onto form elements. A debounce value (milliseconds) can also be provided to useInput()
to prevent too many updates happening in a short space of time.
Internally these function use React hooks, so also must follow React's rules of hooks.
1import {useDendriform, useInput, useCheckbox} from 'dendriform'; 2 3function MyComponent(props) { 4 5 const form = useDendriform(() => ({ 6 name: 'Bill', 7 fruit: 'grapefruit', 8 canSwim: true, 9 comment: '' 10 })); 11 12 return <div> 13 {form.render('name', nameForm => ( 14 <label>name: <input {...useInput(nameForm, 150)} /></label> 15 ))} 16 17 {form.render('fruit', fruitForm => ( 18 <label> 19 select: 20 <select {...useInput(fruitForm)}> 21 <option value="grapefruit">Grapefruit</option> 22 <option value="lime">Lime</option> 23 <option value="coconut">Coconut</option> 24 <option value="mango">Mango</option> 25 </select> 26 </label> 27 ))} 28 29 {form.render('canSwim', canSwimForm => ( 30 <label> 31 can you swim? 32 <input type="checkbox" {...useCheckbox(canSwimForm)} /> 33 </label> 34 ))} 35 36 {form.render('comment', commentForm => ( 37 <label>comment: <textarea {...useInput(commentForm)} /></label> 38 ))} 39 </div>; 40};
You may also have form input components of your own whose onChange
functions are called with the new value rather than a change event. The useProps
hook can be spread onto these elements in a similar way to the useInput
hook. These also support debouncing.
1import {useDendriform, useProps} from 'dendriform'; 2 3function MyComponent(props) { 4 5 const form = useDendriform([]); 6 7 return <MySelectComponent {...useProps(form)} />; 8};
This is equivalent to doing the following:
1return <MySelectComponent 2 value={form.value} 3 onChange={value => form.set(value)} 4/>;
You can subscribe to changes using .onChange
, or by using the .useChange()
hook if you're inside a React component's render method.
The .onChange()
method returns an unsubscribe function you can call to stop listening to changes. The .useChange()
hook automatically unsubscribes when the component unmounts, so it returns nothing.
1const form = new Dendriform({name: 'Bill'});
2
3const unsubscribe = form.onChange(newValue => {
4 console.log('form value was updated:', newValue);
5});
6
7// call unsubscribe() to unsubscribe
8
9function MyComponent(props) {
10 const form = useDendriform({name: 'Ben'});
11
12 form.useChange(newValue => {
13 console.log('form value was updated:', newValue);
14 });
15
16 // ...
17}
As these functions can be called on any form instance, including branched form instances, you can selectively and independently listen to changes in parts of a form's data shape.
1function MyComponent(props) { 2 3 const form = useDendriform(() => ({ 4 firstName: 'Bill', 5 lastName: 'Joe' 6 })); 7 8 useEffect(() => { 9 const unsub1 = form 10 .branch('firstName') 11 .onChange(newName => { 12 console.log('first name changed:', newName); 13 }); 14 15 const unsub2 = form 16 .branch('lastName') 17 .onChange(newName => { 18 console.log('last name changed:', newName); 19 }); 20 21 return () => { 22 unsub1(); 23 unsub2(); 24 }; 25 }, []); 26 27 return <div> 28 {form.render('firstName', firstNameForm => ( 29 <label>first name: <input {...useInput(firstNameForm, 150)} /></label> 30 ))} 31 32 {form.render('lastName', lastNameForm => ( 33 <label>last name: <input {...useInput(lastNameForm, 150)} /></label> 34 ))} 35 </div>; 36};
Alternatively you can use the .useChange()
React hook.
1function MyComponent(props) { 2 const form = useDendriform(() => ({ 3 firstName: 'Bill', 4 lastName: 'Joe' 5 })); 6 7 form.branch('firstName').useChange(newName => { 8 console.log('Subscribing to changes - first name changed:', newName); 9 }); 10 11 form.branch('lastName').useChange(newName => { 12 console.log('Subscribing to changes - last name changed:', newName); 13 }); 14 15 return <div> 16 {form.render('firstName', firstNameForm => ( 17 <label>first name: <input {...useInput(firstNameForm, 150)} /></label> 18 ))} 19 20 {form.render('lastName', lastNameForm => ( 21 <label>last name: <input {...useInput(lastNameForm, 150)} /></label> 22 ))} 23 </div>; 24}
Callbacks passed into .onChange()
are passed a second parameter, an object containing details of the change that took place.
1.onChange((newName, details) => { 2 ... 3});
The detail object contains:
patches: HistoryItem
- The dendriform patches and inverse patches describing this change.
prev.value
- The previous value.next.value
- The new value.go: number
- if undo()
, redo()
or go()
triggered this change, this will be the change to the history index. Otherwise (for example when a call to .set()
triggered the change) it will be 0.replace: boolean
- a boolean stating whether this change was called with replace
.force: boolean
- a boolean stating whether this change was called with force
.id: string
- The id of the form that this change is occuring at.The onChange
and onDerive
functions may initially appear to be very similar, but they have a few key differences.
onDerive
is called once at initial call and every change afterward; onChange
is called only at every change afterward.onDerive
is called during a change, so the data that it is called with may not be the same as the data after the change is complete; onChange
is called after a change and all deriving is complete, so it only ever receives the "final" data.onDerive
may call functions that cause more derivations - any further changes triggered as a result of calling onDerive
are included in the same history item and can be undone with a single call to undo()
; onChange
may also call other functions, but any subsequent would become a new history item.If you always need one form to contain data corresponding to another form's data, use onDerive
. If you want to fire side effects whenever a change has completed successfully, use onChange
.
Common array operations can be performed using array
.
1import {array} from 'dendriform'; 2 3const offsetElement = (form, offset) => { 4 return form.setParent(index => array.move(index, index + offset)); 5}; 6 7function MyComponent(props) { 8 9 const form = useDendriform({ 10 colours: ['Red', 'Green', 'Blue'] 11 }); 12 13 const coloursForm = form.branch('colours'); 14 const shift = useCallback(() => coloursForm.set(array.shift()), []); 15 const pop = useCallback(() => coloursForm.set(array.pop()), []); 16 const unshift = useCallback(() => coloursForm.set(array.unshift('Puce')), []); 17 const push = useCallback(() => coloursForm.set(array.push('Puce')), []); 18 const move = useCallback(() => coloursForm.set(array.move(-1,0)), []); 19 20 return <div> 21 {form.renderAll('colours', colourForm => { 22 23 const remove = useCallback(() => colourForm.set(array.remove()), []); 24 const moveDown = useCallback(() => offsetElement(colourForm, 1), []); 25 const moveUp = useCallback(() => offsetElement(colourForm, -1), []); 26 27 return <div> 28 <label>colour: <input {...useInput(colourForm, 150)} /></label> 29 30 <button onClick={remove}>remove</button> 31 <button onClick={moveDown}>down</button> 32 <button onClick={moveUp}>up</button> 33 </div>; 34 })} 35 36 <button onClick={shift}>shift</button> 37 <button onClick={pop}>pop</button> 38 <button onClick={unshift}>unshift</button> 39 <button onClick={push}>push</button> 40 <button onClick={move}>move last to first</button> 41 </div>; 42}
Dendriform can keep track of the history of changes and supports undo and redo. Activate this by specifying the maximum number of undos you would like to allow in the options object when creating a form.
History items consist of immer patches that have been optimised, so they take up very little memory in comparison to full state snapshots.
1const form = new Dendriform({name: 'Bill'}, {history: 50});
2// ...
3
4function MyComponent(props) {
5 const form = useDendriform({name: 'Ben'}, {history: 50});
6 // ...
7}
History can be navigated by calling .undo()
and .redo()
on any form. It does not matter if you are calling these on the top level form or any branched form, the effect will be the same.
1function MyComponent(props) { 2 3 const form = useDendriform(() => ({name: 'Ben'}), {history: 100}); 4 5 return <div> 6 {form.render('name', nameForm => ( 7 <label>name: <input {...useInput(nameForm, 150)} /></label> 8 ))} 9 10 {form.render(form => { 11 const {canUndo, canRedo} = form.useHistory(); 12 // this function will only re-render if canUndo or canRedo changes 13 return <> 14 <button onClick={form.undo} disabled={!canUndo}>Undo</button> 15 <button onClick={form.redo} disabled={!canRedo}>Redo</button> 16 </>; 17 })} 18 </div>; 19}
The .go()
function can also be used to perform undo and redo operations.
1form.go(-1); // equivalent to form.undo() 2form.go(1); // equivalent to form.redo() 3form.go(-3); // equivalent to form.undo() called 3 times in a row 4form.go(0); // does nothing
You can find if the form is able to undo or redo using .history
, or by using the .useHistory()
hook if you're inside a React component's render method. These both return an object {canUndo: boolean, canRedo: boolean}
. This can be used to disable undo and redo buttons.
1function MyComponent(props) { 2 3 const form = useDendriform(() => ({name: 'Ben'}), {history: 100}); 4 5 return <div> 6 {form.render('name', nameForm => ( 7 <label>name: <input {...useInput(nameForm, 150)} /></label> 8 ))} 9 10 {form.render(form => { 11 const {canUndo, canRedo} = form.useHistory(); 12 // this function will only re-render if canUndo or canRedo changes 13 return <> 14 <button onClick={form.undo} disable={!canUndo}>Undo</button> 15 <button onClick={form.redo} disable={!canRedo}>Redo</button> 16 </>; 17 })} 18 </div>; 19};
You can also control how changes are grouped in the history stack.
The .replace()
function can be used to prevent a new history item being created for the next .set()
.
1const form = new Dendriform('a', {history: 50}); 2 3form.set('b'); 4// form will contain 'b' as a new history item 5// if undo() is called, form will contain 'a' again 6 7// ...after some time... 8 9form.replace(); 10form.set('c'); 11// form will contain 'c' by updating the current history item 12// if undo() is called, form will contain 'a' again
The .replace()
function can also take a boolean for convenience.
1form.replace(true); 2// equivalent to form.replace(); 3 4form.replace(false); 5// equivalent to not calling form.replace() at all
Buffering multiple changes also works with .replace()
.
1const form = new Dendriform(1, {history: 50}); 2 3form.set(2); 4// form will contain 2 as a new history item 5// if undo() is called, form will contain 1 again 6 7// ...after some time... 8 9form.replace(); 10form.buffer(); 11form.set(num => num + 1); 12form.set(num => num + 1); 13form.done(); 14 15// form will contain 4 by updating the current history item 16// if undo() is called, form will contain 1 again
The .buffer()
function can also be called again while buffering to add subsequent changes to a new history item. The changes still will not be applied until .done()
is called.
1const form = new Dendriform('a', {history: 50}); 2 3// calling .set() multiple times in the same update 4form.buffer(); 5form.set('b'); 6form.set('c'); 7form.done(); 8 9// form will contain 'c' 10// if undo is called, form will contain 'a' again 11 12// calling .set() multiple times in the same update 13form.buffer(); 14form.set('b'); 15form.buffer(); 16form.set('c'); 17form.done(); 18 19// form will contain 'c' 20// if undo is called, form will contain 'b' 21// if undo is called a second time, form will contain 'a'
Drag and drop can be implemented easily with libraries such as react-beautiful-dnd, because dendriform takes care of the unique keying of array elements for you.
1import {DragDropContext, Droppable, Draggable} from 'react-beautiful-dnd'; 2 3const dndReorder = (result) => (draft) => { 4 if(!result.destination) return; 5 6 const startIndex = result.source.index; 7 const endIndex = result.destination.index; 8 if(endIndex === startIndex) return; 9 10 const [removed] = draft.splice(startIndex, 1); 11 draft.splice(endIndex, 0, removed); 12}; 13 14function DragAndDrop() { 15 16 const form = useDendriform({ 17 colours: [ 18 {colour: 'Red'}, 19 {colour: 'Green'}, 20 {colour: 'Blue'} 21 ] 22 }); 23 24 const onDragEnd = useCallback(result => { 25 form.branch('colours').set(dndReorder(result)); 26 }, []); 27 28 const onAdd = useCallback(() => { 29 form.branch('colours').set(array.push('Puce')); 30 }, []); 31 32 return <div> 33 <DragDropContext onDragEnd={onDragEnd}> 34 <Droppable droppableId="list"> 35 {provided => ( 36 <div ref={provided.innerRef} {...provided.droppableProps}> 37 <DragAndDropList form={form.branch('colours')} /> 38 {provided.placeholder} 39 </div> 40 )} 41 </Droppable> 42 </DragDropContext> 43 44 <button onClick={push}>add new</button> 45 </div>; 46} 47 48function DragAndDropList(props) { 49 return props.form.renderAll(eachForm => { 50 51 const id = \`$\{eachForm.id}\`; 52 const index = eachForm.useIndex(); 53 const remove = useCallback(() => eachForm.set(array.remove()), []); 54 55 return <Draggable key={id} draggableId={id} index={index}> 56 {provided => <div 57 ref={provided.innerRef} 58 {...provided.draggableProps} 59 {...provided.dragHandleProps} 60 > 61 <label>colour: <input {...useInput(eachForm.branch('colour'), 150)} /></label> 62 <button onClick={remove}>remove</button> 63 </div>} 64 </Draggable>; 65 }); 66}
Dendriform has a plugin system that allows modular resuable functionality to be applied to forms, such as adding a submit action to a form.
Plugin instances become available on all branched forms under form.plugins
. Some plugin methods can return data relevant to the branched form that the plugin is called from.
1const plugins = { 2 foo: new MyPlugin() 3}; 4 5const form = new Dendriform({bar: 123}, {plugins}); 6// form.plugins.foo is the MyPlugin instance 7// form.branch('foo').plugins.foo is also the MyPlugin instance
If you are passing plugins to the useDendriform
hook, plugins
must be a function that returns a plugins object.
1function MyComponent() { 2 const plugins = () => ({ 3 foo: new MyPlugin() 4 }); 5 6 const form = useDendriform({bar: 123}, {plugins}); 7 // ... 8}
Adds a submit action to a form. The onSubmit
callback will be called when a form is submitted. If any errors occur as a result of calling onSubmit
, the form will roll back and allow it to be submitted again.
The onSubmit
function passes the same details
object as onChange
does, so it's possible to use diff()
with this to find what has changed between submissions. Please note that as of v2.2.0
using diff
in onSubmit
works with all data types except for diffing arrays.
1import {PluginSubmit} from 'dendriform'; 2 3type SubmitValue = { 4 firstName: string; 5 lastName: string; 6}; 7 8type SubmitPlugins = { 9 submit: PluginSubmit<SubmitValue,string>; 10}; 11 12const plugin: SubmitPlugins = { 13 submit: new PluginSubmit<SubmitValue,SubmitPlugins>({ 14 onSubmit: async (newValue: SubmitValue, details): void => { 15 // trigger save action here 16 // any errors will be eligible for resubmission 17 // diff(details) can be used in here to diff changes since last submit 18 }, 19 onError: (error: any): string|undefined => { 20 // optional function, will be called if an error occurs in onSubmit 21 // anything returned will be stored in state in form.plugins.submit.error 22 // the error state is cleared on next submit 23 } 24 }) 25}; 26 27const form = new Dendriform(value, {plugins}); 28 29// form plugin can be found form.plugins.submit 30// form can be submitted by calling: 31form.plugins.submit.submit(); 32 33// get whether the form has changed value i.e. is dirty 34form.plugins.submit.dirty.value; 35form.plugins.submit.dirty.useValue(); 36 37// get whether the form has changed value at a path 38form.branch('foo').plugins.submit.dirty;
PluginSubmit has the following properties and methods.
submit(): void
- submits the form if there are changes, calling onSubmit
. If the value of the form has not changed then this has no effect.previous: Dendriform<V>
- a dendriform containing the inital value / the value of the previous submit at the current branch.submitting: Dendriform<boolean>
- a dendriform containing a boolean stating if the plugin is currently waiting for an async onSubmit
call to complete.submitting: Dendriform<E|undefined>
- a dendriform containing the most recent result of onError
. This is changed to undefined
on submit.dirty.value: boolean
- a boolean indicating if the value at the current branch is dirty i.e. has changed.dirty.useValue(): boolean
- a React hook returning a boolean indicating if the value at the current branch is dirty i.e. has changed.Dendriform can produce diffs of changes in .onChange
and .onDerive
callbacks. It does this shallowly, only looking at how the child values / elements of the newValue
has changed.
1import {diff} from 'dendriform'; 2 3const form = new Dendriform({ 4 a: 1, 5 b: 2, 6 c: 3 7}); 8 9form.onChange((newValue, details) => { 10 const [added, removed, updated] = diff(details); 11 12 // - added will be an array of {key: string, value: number} objects 13 // that have been added by the change 14 // - removed will be an array of {key: string, value: number} objects 15 // that have been removed by the change 16 // - updated will be an array of {key: string, value: number} objects 17 // that have been updated by the change 18}); 19 20// if form was to be set to the following 21 22form.set({ 23 b: 2, 24 c: 33, 25 d: 44 26}); 27 28// then diff() will return 29// added: [{key: 'd', value: 44}] 30// removed: [{key: 'a', value: 1}] 31// updated: [{key: 'c', value: 33}]
When a change occurs, you can derive additional data in your form using .onDerive
, or by using the .useDerive()
hook if you're inside a React component's render method. Each derive function is called once immediately, and then once per change after that. When a change occurs, all derive callbacks are called in the order they were attached, after which .onChange()
, .useChange()
and .useValue()
are updated with the final value.
The .onDerive()
method returns an unsubscribe function you can call to stop deriving. The .useDerive()
hook automatically unsubscribes when the component unmounts, so it returns nothing.
1const form = new Dendriform({ 2 a: 1, 3 b: 2, 4 sum: 0 5}); 6 7const unsubscribe = form.onDerive(newValue => { 8 form.branch('sum').set(newValue.a + newValue.b); 9}); 10 11// now form.value is {a:1, b:2, sum:3} 12 13// call unsubscribe() to unsubscribe
1function MyComponent(props) {
2 const form = useDendriform({a: 1, b: 2, sum: 0});
3
4 form.useDerive(newValue => {
5 form.branch('sum').set(newValue.a + newValue.b);
6 });
7
8 // if form.branch('a').set(2); is called
9 // the deriver function will be called
10 // and form.value will contain {a:2, b:2, sum:4}
11}
It is also possible and often preferrable to make changes in other forms in .onDerive()
's callback.
Here we can see that deriving data can be useful for implementing validation.
1const form = new Dendriform({name: 'Bill'}); 2const validState = new Dendriform({ 3 nameError: '', 4 valid: true 5}); 6 7form.onDerive(newValue => { 8 const valid = newValue.name.trim().length > 0; 9 const nameError = valid ? '' : 'Name must not be blank'; 10 validState.branch('valid').set(valid); 11 validState.branch('nameError').set(nameError); 12});
Callbacks passed into .onDerive()
are passed a second parameter, an object containing details of the change that took place.
1.onDerive((newName, details) => { 2 ... 3});
The detail object contains:
patches: HistoryItem
- The dendriform patches and inverse patches describing this change.
prev.value
- The previous value.next.value
- The new value.go: number
- if undo()
, redo()
or go()
triggered this change, this will be the change to the history index. Otherwise (for example when a call to .set()
triggered the change) it will be 0.replace: boolean
- a boolean stating whether this change was called with replace
.force: boolean
- a boolean stating whether this change was called with force
.id: string
- The id of the form that this derive is occuring at.The onChange
and onDerive
functions may initially appear to be very similar, but they have a few key differences.
onDerive
is called once at initial call and every change afterward; onChange
is called only at every change afterward.onDerive
is called during a change, so the data that it is called with may not be the same as the data after the change is complete; onChange
is called after a change and all deriving is complete, so it only ever receives the "final" data.onDerive
may call functions that cause more derivations - any further changes triggered as a result of calling onDerive
are included in the same history item and can be undone with a single call to undo()
; onChange
may also call other functions, but any subsequent would become a new history item.If you always need one form to contain data corresponding to another form's data, use onDerive
. If you want to fire side effects whenever a change has completed successfully, use onChange
.
You can also derive in both directions. Here a numberForm
and a stringForm
are created, and changes to one are derived into the other without causing an infinite loop.
1const numberForm = new Dendriform(10); 2const stringForm = new Dendriform(''); 3 4// add first deriver 5numberForm.onDerive(value => { 6 stringForm.set(`${value}`); 7}); 8 9// at this point stringForm.value is now '10' 10 11// add second deriver 12stringForm.onDerive(({val}) => { 13 numberForm.set(Number(val)); 14}); 15 16// now set number, string will derive 17numberForm.set(20); 18// numberForm.value === 20 19// stringForm.value === '20' 20 21// now set string, number will derive 22stringForm.set('30'); 23// numberForm.value === 30 24// stringForm.value === '30'
You can use any number of forms to store your editable state so you can keep related data grouped logically together. However you might also want several separate forms to move through history together, so calling .undo()
on one will also undo the changes that have occurred in multiple forms. The syncHistory
utility can do this.
Synchronised forms must have the same maximum number of history items configured, and syncHistory
must be called before any of the affected forms have any changes made to them.
1import {syncHistory} from 'dendriform'; 2 3const nameForm = new Dendriform({name: 'Bill'}, {history: 100}); 4const addressForm = new Dendriform({street: 'Cool St'}, {history: 100}); 5 6syncHistory(nameForm, addressForm); 7 8// if nameForm.undo() is called, addressForm.undo() is also called, and vice versa 9// if nameForm.redo() is called, addressForm.redo() is also called, and vice versa 10// if nameForm.go() is called, addressForm.go() is also called, and vice versa
Multiple forms can be synced together through multiple calls fo syncHistory
, so it's possible to append forms to a groups of already-synchronised forms.
1import {syncHistory} from 'dendriform';
2
3const nameForm = new Dendriform('Noof', {history: 100});
4const addressForm = new Dendriform('12 Foo St', {history: 100});
5const suburbForm = new Dendriform('Suburbtown', {history: 100});
6
7syncHistory(nameForm, addressForm, suburbForm);
8
9// later, but before any changes are made to any affected forms, other forms can be synchronised
10
11const colourForm = new Dendriform('Blue', {history: 100});
12
13syncHistory(suburbForm, colourForm);
14
15// now nameForm, addressForm, suburbForm and colourForm will be synchronised through history
Inside of a React component you can use the useHistorySync()
hook to achieve the same result.
1import {useHistorySync} from 'dendriform'; 2 3function MyComponent(props) { 4 const personForm = useDendriform(() => ({name: 'Bill'}), {history: 100}); 5 const addressForm = useDendriform(() => ({street: 'Cool St'}), {history: 100}); 6 7 useHistorySync(personForm, addressForm); 8 9 return <div> 10 {personForm.render('name', nameForm => ( 11 <label>name: <input {...useInput(nameForm, 150)} /></label> 12 ))} 13 14 {addressForm.render('street', streetForm => ( 15 <label>street: <input {...useInput(streetForm, 150)} /></label> 16 ))} 17 18 {personForm.render(personForm => { 19 const {canUndo, canRedo} = personForm.useHistory(); 20 return <div> 21 <button onClick={personForm.undo} disabled={!canUndo}>Undo</button> 22 <button onClick={personForm.redo} disabled={!canRedo}>Redo</button> 23 </div>; 24 })} 25 </div>; 26}
Forms can have constraints applied to prevent invalid data from being set. An .onDerive()
/ .useDerive()
callback may optionally throw a cancel()
to cancel and revert the change that is being currently applied.
1import {cancel} from 'dendriform'; 2 3const form = new Dendriform(1); 4 5form.onDerive((value) => { 6 if(value === 2) { 7 throw cancel('Two not allowed'); 8 } 9}); 10 11// calling form.set(2) will not result in any change
The .onDerive()
callback can be written to obey a force
flag, which can be passed into .set()
.
1const form = new Dendriform(1); 2 3form.onDerive((value, {force}) => { 4 if(value === 2 && !force) { 5 throw cancel('Two not allowed'); 6 } 7}); 8 9// calling form.set(2) will not result in any change 10// calling form.set(2, {force: true}) will result in a change
You can provide a callback function to be called whenever a change is cancelled using .onCancel
, or by using the .useCancel()
hook if you're inside a React component's render method.
1const form = new Dendriform(1); 2 3form.onDerive((value) => { 4 if(value === 2) { 5 throw cancel('Two not allowed'); 6 } 7}); 8 9form.onCancel((reason) => { 10 console.warn(reason); 11}) 12 13// calling form.set(2) will not result in any change 14// and 'Two not allowed' will be logged to the console
The cancel feature can be used to set up data integrity constraints between forms. If a form changes and derives data into other forms, then any other form that updates as a result can trigger the entire change to be cancelled using cancel()
. This is a powerful feature that can allow you to set up foreign key constraints between forms.
Warning: the {LazyDerive} API is experimental and may be replaced or removed in future.
The LazyDerive class can be used when derivations are heavy or asynchronous, and it makes more sense to only perform these derivations lazily, i.e. when something asks for the derived data. The derivation is cached until any of its dependencies change, at which point the cache is cleared. If it has any current subscribers using lazyDeriver.onChange()
or lazyDeriver.useValue()
then a new derivation will start immediately.
A LazyDerive can be created using new LazyDerive
, or by using the useLazyDerive()
hook if you're inside a React component's render method. The deriver function is passed as the first argument to the constructor or hook, and an array of dependencies are passed as the second argument. Dependencies must be Dendriform
instances or LazyDerive
instances. This allows LazyDerive
s to derive from each other.
To access the value, use lazyDerive.value
, This returns a promise that will resolve when the derivation is complete, or resolve immediately if the derivation is already cached.
Unlike Dendriform
there is no restrictions to the data type that LazyDerive
can contain.
1import {LazyDerive} from 'dendriform'; 2 3const name = new Dendriform('Bob'); 4const age = new Dendriform(12); 5 6const lazyImage = new LazyDerive(async () => { 7 return await renderLargeImage(`I am ${name.value} and I am ${age.value}`); 8}, [name, age]); 9 10// or useLazyDerive(async () => ..., [name, age]) in a React component 11 12// later something may call the image 13await lazyImage.value; 14 15// or in React a component may always want to render the result 16function MyComponent(props) { 17 const src = lazyImage.useValue(); 18 return src && <img src={src} />; 19}
The useValue()
hook may optionally be passed a boolean indicating whether the previous value should be rendered if a new derivation is taking place. This defaults to false
, meaning that the result of lazyDerive.useValue()
will change to undefined
as soon as a new derivation begins.
The status of the derivation can be accessed from lazyDerive.status
. This is a Dendriform that contains an object with the following keys:
deriving: boolean
- true if the derivation is in progress.derived: boolean
- true if a derivation has completed.As this is a Dendriform, all the normal Dendriform usage patterns can be used (i.e. lazyDerive.status.branch('deriving').useValue()
).
The lazyDerive.unsubscribe()
function should be called when a LazyDerive
is no longer needed to prevent memory leaks.
"Dendriform" means "tree shaped", referencing the tree-like manner in which you can traverse and render the parts of a deep data shape.
Demos can be found on dendriform.xyz.
This library is written and maintained by Damien Clarke. All online library discussion happens over on Github.
I hope this library helps solve some data-editing user interface problems for you. 🎉
No vulnerabilities found.
No security vulnerabilities found.