objerve

1.0.6 • Public • Published

objerve

Define callbacks that get fired when given object properties change.

picture

example

const objerve = require('./main.js')

const obj = objerve()

objerve.addListener(obj, ['a', 'b'], (newValue, oldValue, action, path, obj) => {
  console.log(`${action} ${path.join('.')}: `+
    `${JSON.stringify(oldValue)} -> ${JSON.stringify(newValue)}`)
})

obj.a = { b: 'hi' }
obj.a.b = 'hello'
obj.a = null
create a.b: undefined -> "hi"
change a.b: "hi" -> "hello"
delete a.b: "hello" -> undefined

features

  • Behaves exactly like an ordinary Object
  • Can listen to fixed paths or to all paths with a prefix, and use objerve.each inside paths to match all array indexes.
  • Putting objerve instances (or parts of them) inside each others' properties fully works, and property changes are propagated between instances as you'd expect if they are properties of each other (even with circular references)
  • Can tell apart undefined property value and property deletion, thanks to the action argument passed to callbacks (shows created / changed / deleted).
  • Calls your callbacks in a nesting-respecting order, so your callbacks can setup and teardown state in the correct order (bottom-up construction, top-down destruction)
  • Stores listeners in a prefix tree by target path, to speed up queries with large objects and many listeners.

api

objerve([obj])

Wrap the given object (default {}) so it can be subscribed to.

The resulting object behaves like the object did before, but changes to its paths can be listened to with objerve.addListener or objerve.addPrefixListener.

objerve.addListener(obj, path, callback)

The path can contain objerve.each, which will match any Array index at that position.

Works inside listener callbacks. If inside a listener you add a new listener that matches the same path, the new listener will also be called with this same change.

objerve.removeListener(obj, path, callback)

Remove the listener from the given path, so the callback is no longer called. The path is useful to disambiguate in case the same callback function is being used as the listener for multiple paths.

Does nothing if it cannot find such a listener.

Works inside listener callbacks. If you remove a listener for the same path inside a callback for that path, the removed listener won't be called for that change either (unless it was already called before this one).

objerve.addPrefixListener(obj, path, callback)

Same as addListener, but will be called for any property at all that has the given path as a prefix. Pass [] for the path to be called for every change to any property.

objerve.removePrefixListener(obj, path, callback)

Same as removeListener, but for prefix listeners.

objerve.each

A special Symbol value that can be passed as part of a path to listen to. It matches any valid array index, i.e. 0, 1, 999999999 etc, so your listener is called for every element of an array being created, changed, or deleted.

how callbacks are called

Your callback function is called immediately before the described change is actually applied to the object, with these arguments:

  • newValue

  • oldValue

  • action: One of the following strings:

    • 'create' if the property did not exist, and is being created
    • 'change' if the property exists, and its value is changing
    • 'delete' if the property exists, but it is being deleted

    Example: Using the action> argument to distinguish property deletion from being set to undefined
    const objerve = require('./main.js')
    const obj = objerve()
    
    objerve.addListener(obj, ['x'], (newValue, oldValue, action) => {
      console.log(`${action} ${oldValue} -> ${newValue}`)
    })
    
    obj.x = true
    obj.x = undefined
    delete obj.x
    create undefined -> true
    change true -> undefined
    delete undefined -> undefined
    

    Note how although both the obj.x = undefined and delete obj.x lines triggered a callback with newValue undefined, their actions differed: 'change' and 'delete'.

  • path: An Array representing the property path through the object at which this update happened. Useful if you have a single callback function listening to multiple paths.

  • obj: A reference to the object as it currently exists (just before the described update is actually applied).

  • updateId: A number uniquely identifying the currently happening change. All listeners that get called due to the same change (or caused by a callback reacting to the same change) see the same identifier.

    Example: same update id when a listener itself triggers a change
    const objerve = require('./main.js')
    const obj = objerve()
    
    // Listen to changes to 'obj.a'.  Reduce it by 1 unless it's 0.
    objerve.addListener(obj, ['a'],
      (val, previousVal, action, path, objRef, updateId) => {
        console.log(`[${action}] ${previousVal} -> ${val} (updateId ${updateId})`)
        if (val > 0) {
          obj.a = val - 1
        }
      })
    // Also create a listener listening to all properties on 'obj'.
    objerve.addPrefixListener(obj, [],
      (val, previousVal, action, path, objRef, updateId) => {
        console.log(`prefix listener called (updateId ${updateId})`)
      })
    
    obj.a = 3
    console.log(obj.a)
    obj.a = 2
    console.log(obj.a)

    Each time something is assigned to obj.a, the first listener gets called, and assigns it 1 lower, until it's 0:

    prefix listener called (updateId 0)
    [create] undefined -> 3 (updateId 0)
    prefix listener called (updateId 0)
    [create] undefined -> 2 (updateId 0)
    prefix listener called (updateId 0)
    [create] undefined -> 1 (updateId 0)
    prefix listener called (updateId 0)
    [create] undefined -> 0 (updateId 0)
    0
    prefix listener called (updateId 1)
    [change] 0 -> 2 (updateId 1)
    prefix listener called (updateId 1)
    [change] 0 -> 1 (updateId 1)
    prefix listener called (updateId 1)
    [change] 0 -> 0 (updateId 1)
    0
    

    Note that for each individual change (obj.a = 3 and obj.a = 2), both listeners were called multiple times, but during each change both were called with the same updateId.

If your callback wants to cancel the described change from happening, simply assign a value to the property being changed and it will take priority.

Note that callbacks are always called for every matching change, even if changes essentially invalidate previous ones by overwriting their values. Some use-cases (such as updating a UI in response to property changes) may only care about the final results at the end of this event loop tick, so you may wish to accumulate the changes and defer your rendering with an API appropriate for your use-case (such as setImmediate, process.nextTick, queueMicrotask, requestAnimationFrame, etc).

Example: Accumulating changes and deferring rendering using process.nextTick
const objerve = require('./main.js')
const ArrayKeyedMap = require('array-keyed-map')

const obj = objerve()
const accumulatedChanges = new ArrayKeyedMap()

const render = () => {
  // Put your expensive UI rendering code here
  console.log(Array.from(accumulatedChanges.entries()))
  accumulatedChanges.clear()
}

objerve.addListener(obj, ['a'],
  (newVal, oldVal, action, path) => {
    if (accumulatedChanges.size === 0) process.nextTick(render)
    if (!accumulatedChanges.has(path)) {
      accumulatedChanges.set(path, {newVal, oldVal})
    } else {
      accumulatedChanges.get(path).newVal = newVal
    }
  })

// Make a bunch of changes
obj.a = 1
obj.a = 2
obj.a = 3

The render function only gets called on next event loop tick tick, with the total accumulated change from undefined to 3, and none of the intermediate states between:

[ [ [ 'a' ], { newVal: 3, oldVal: undefined } ] ]

call order

When one change triggers multiple callbacks, the order they are called depends on whether the change is constructive or destructive: If the property is being created or changed, callbacks are called in root→leaf order. If the property is being deleted, callbacks are called in leaf→root order.

Because of this feature, your listeners can setup or teardown state (e.g. managing DOM elements) in response to creation or deletion, and sub-properties can use that state (e.g. appending their own DOM elements to the parent's ones) while still being able to clean up the sub-properties' state gracefully and in the right order even when a whole chain of properties is deleted all at once.

Example: Construction and destruction call order
const objerve = require('./main.js')
const obj = objerve()

const callback = (name) => {
  return (val, previousVal, action) => {
    console.log(`${action} ${name}`)
  }
}

objerve.addListener(obj, ['a'], callback('a'))
objerve.addListener(obj, ['a', 'b'], callback('a.b'))
objerve.addListener(obj, ['a', 'b', 'c'], callback('a.b.c'))

obj.a = { b: { c: 'value' } }
delete obj.a
create a
create a.b
create a.b.c
delete a.b.c
delete a.b
delete a

Prefix listeners and objerve.each-matching listeners are also considered "parents" of concrete property paths, so their listeners are called before the concrete path's listeners on creation/change (prefix→each→concrete), and after them on deletion (concrete→each→prefix).

Example: Construction and destruction call order, with prefix- and objerve.each-listeners
const objerve = require('./main.js')
const obj = objerve([])

const callback = (name) => {
  return (val, previousVal, action) => console.log(`${action} ${name}`)
}

// Listen for property '0'
objerve.addListener(obj, [0], callback('concrete'))
// Listen for any array index
objerve.addListener(obj, [objerve.each], callback('each'))
// Listen for all properties
objerve.addPrefixListener(obj, [], callback('prefix'))

obj[0] = true
delete obj[0]
create prefix
create each
create concrete
delete concrete
delete each
delete prefix

If there are multiple listeners for a property that changes, the listeners are called in insertion order.

Other than the above rules, the relative order in which any two paths' callbacks are called may be arbitrary, so you shouldn't rely on it.

use-cases

  • Binding data to UI.
  • Testing. Transparently adding logging to property changes is handy.
  • Reactive programming.

license

ISC; summary: use for anything, credit me, no warranty

Readme

Keywords

none

Package Sidebar

Install

npm i objerve

Weekly Downloads

1

Version

1.0.6

License

ISC

Unpacked Size

95.5 kB

Total Files

8

Last publish

Collaborators

  • an