dentata: the American Chestnut of data trees
npm install dentata
yarn add dentata
const { Dentata } = require('dentata') // import { Dentata } from 'dentata'
// Objects, arrays, and functions are supported
const tree = new Dentata({arr: [1, 2, 3], x: 'foo', myCallback: () => {}})
const x = tree.select('x')
x.set('bar')
// You can make a cursor from a primitive value too
const num = new Dentata(5)
num.get() // 5
num.set(6)
num.onChange((next, last) => console.log('difference:', next - last))
num.apply(prev => prev + 1)
// There is no difference between a root cursor and a selected subcursor
- Simple, lean, and fully-typed data tree library with change listeners for node and the browser, javscript or typescript. A state manager that keeps things simple, fast, and understandable. A minimalist/bare-bones alternative to baobab. (Not to mention redux, etc.)
- Zero dependencies and 2.7kb gzipped.
- It is fully synchronous so no surprises waiting for your changes to propagate, or passing callbacks to set, which avoids many errors in both UIs and APIs.
- You make a tree/cursor with
new Dentata(data)
and just haveget
,set
,apply(update: old => new)
, andonChange(handler)
. This is flexible enough to manage state server-side, with simple DOM-based apps, in react, or in libraries. A change event will only fire if the new data is actually different, and will always fire if anything at or below the cursor is different. - Values from
get
andapply
andonChange
are deeply immutable via typescript's readonly modifier. So if you are using typescript then you will never mess up your tree by accidentally modifying a return value. - Thanks to an optimized deep equality check, all of this is very fast. The diff is only taken on nodes that have children or listeners, so it is often avoided.
- If your editor supports typescript well (e.g. vscode) then you also get auto-complete for keys and compile-time errors for invalid keys or values.
Auto-complete and compile-time errors
Longer example
This whole thing will run if you copy-paste it into node
const { Dentata } = require('dentata')
// or:
// import { Dentata } from 'dentata';
// Make a new data tree. The root cursor is just like any other cursor.
const dentata = new Dentata({array: [5,6,7], nested: {objects: {are: 'fine'}}})
// Select some cursors inside the tree:
const arrayCursor = dentata.select('array')
// `s` is an alias for `select`
const areCursor = dentata.s('nested').s('objects').s('are')
// We'll just log changes to our cursors. More useful onChangers would update UI or trigger server actions or recalculate a value or whatever.
arrayCursor.onChange((next, last) => console.log('array changed from', last, 'to', next))
areCursor.onChange((next, last) => console.log('are changed from', last, 'to', next))
dentata.onChange((next, last) => console.log('entire tree changed from', last, 'to', next))
// Listeners are not triggered if the data is equal according to Dentata.deepEquals
dentata.set({array: [5,6,7], nested: {objects: {are: 'fine'}}})
arrayCursor.apply(last => [...last, 8])
// log: array changed from [ 5, 6, 7 ] to [ 5, 6, 7, 8 ]
// log: entire tree changed from { array: [ 5, 6, 7 ], nested: { objects: { are: 'fine' } } } to { array: [ 5, 6, 7, 8 ], nested: { objects: { are: 'fine' } } }
arrayCursor.select(0).set(555)
// log: array changed from [ 5, 6, 7, 8 ] to [ 555, 6, 7, 8 ]
// log: entire tree changed from { array: [ 5, 6, 7, 8 ], nested: { objects: { are: 'fine' } } } to { array: [ 555, 6, 7, 8 ], nested: { objects: { are: 'fine' } } }
areCursor.set('okay')
// log: are changed from fine to okay
// log: entire tree changed from { array: [ 555, 6, 7, 8 ], nested: { objects: { are: 'fine' } } } to { array: [ 555, 6, 7, 8 ], nested: { objects: { are: 'okay' } } }
dentata.apply(d => ({...d, newKey: 'newVal'}))
// log: entire tree changed from { array: [ 555, 6, 7, 8 ], nested: { objects: { are: 'okay' } } } to { array: [ 555, 6, 7, 8 ], nested: { objects: { are: 'okay' } }, newKey: 'newVal' }
// setting a value to undefined releases all cursors and listeners:
dentata.set(undefined)
// (all three listeners fire)
dentata.set(null)
// (no listeners fire)
React example
No more passing val1, setVal1, val2, setVal2 through props! Just pass the cursor, or select it from the root, or export it as a constant. There's no render cycle, parent context, transpilation, daemon, etc, it's just a data tree.
// Write the appropriate hook for react, preact, vue, mithril, or whatever:
function useDentata(cursor) {
const [val, setVal] = useState(cursor.get())
cursor.onChange(next => setVal(next))
return val
}
const usernameCursor = tree.select('username')
function User() {
const username = useDentata(usernameCursor)
return <h1>You are {username}.</h1>
}
function AnotherButtonSomewhereElse() {
return <button onClick={() => usernameCursor.set('new username')}>Click</button>
}
// Or take a cursor as a prop
function Points(props: {points: Dentata<number>}) {
return <div>total pointage: {props.points.get()}</div> // works
}
Compose cursors
// Use the helper:
import { syntheticCursor } from 'dentata'
const sumCursor = syntheticCursor(tree.select('numbers'), nums => nums.reduce((x, y) => x + y, 0))
const currentSum = sumCursor.get()
sumCursor.onChange(newSum => myDiv.innerText = `sum: ${newSum}`)
// synthetic cursors do not have `set` or `select`, naturally.
// Or you can roll your own:
function makeAreaCursor(rectangleCursor) {
const listeners = []
const areaOf = { width, height } => width * height
return {
get: () => areaOf(rectangleCursor)
set: (newArea) => {
const side = Math.sqrt(newArea)
rectangleCursor.set({width: side, height: side})
}
}
}
const area = makeAreaCursor(rectangleCursor)
console.log(area.get())
Performance
Results from the "is reasonably fast" test in index.test.ts
in node v17.4.0 on a 4-core 2015 macbook pro:
- 100k separate trees in 0.063 seconds
- Separately setting 100k values in a mixed-depth tree with about 100 nodes having cursors: 1.4 seconds
- One 2k-node mixed-depth tree with cursors and onChange listeners on every node: 1.2 seconds
- Making one tree all at once from a giant object is basically instant
- For comparison, making a 100k-value plain object took 0.04 seconds and 100k function instantiations + calling took 0.04 seconds.
Contribution
Pull requests and new issues are welcome. I don't want to make it too complicated. If you want a big new feature then I recommend making a fork, or checking out something like baobab or redux. Please do file an issue right away if you notice a bug or performance problem
Full API
class Dentata<T> {
constructor(data: T);
// Get the current value at the cursor
get(): DeepReadonly<T>;
// Set data of current cursor and notify relevant onChange listeners. Set to `undefined` to remove all listeners and descendant cursors.
set(newVal: T): void;
// Set value at key
setIn<K extends keyof T>(k: K, val: T[K]): void;
// Alias for get + set. Update the old value into a new value. Do not mutate the argument.
apply(update: (prev: DeepReadonly<T>) => T): void;
// Get a cursor deeper into the tree. It will be notified of parent changes and will tell parent if it changes (if either has change listeners).
select<K extends keyof T>(key: K): Dentata<T[K]>;
// Alias for Dentata.select
s<K extends keyof T>(key: K): Dentata<T[K]>;
// Listen for changes to the data at this cursor, including changes originating in parents or children.
onChange(handleChange: Listener<T>): void;
// Remove all onChange listeners on this cursor
clearListeners(): void;
}
// An onChange callback
type Listener<T> = (newVal: DeepReadonly<T>, oldVal: DeepReadonly<T>) => void;
// Alias for Dentata
const Dent: typeof Dentata;
type Dent<T> = Dentata<T>;
// Return type of syntheticCursor
interface DentataLike<T> {
get: () => DeepReadonly<T>;
onChange: (l: Listener<T>) => void;
}
// Create a synthetic data cursor for computed values on another data cursor
function syntheticCursor<InputData, OutputData>(
fromCursor: DentataLike<InputData>,
compute: (t: DeepReadonly<InputData>) => OutputData,
settings?: { equality: "===" | "deep"; }
): DentataLike<OutputData>;
// The equality algorithm, mainly exported so you can test it for your particular case
function deepEquals (a: unknown, b: unknown) => boolean;