Incrementally computed signals
One of Signia's superpowers is its support for incremental recomputation of derived values. This makes it possible to work with large derived collections extremely efficiently, while still benefitting from the lazy evaluation and always-on caching that Signia provides.
This is achieved using a clock-based reactivity system.
Clocks and Epochs
Signia has a global logical clock. This is an integer that gets incremented every time any atom is updated.
An epoch is one specific value of the global clock. It is a virtual point in time.
You can access the epoch upon which a signal's value last changed using the lastChangedEpoch
property:
const firstName = atom('firstName', 'Brian')
const startEpoch = firstName.lastChangedEpoch
firstName.set('Steve')
const endEpoch = firstName.lastChangedEpoch
console.log(endEpoch - startEpoch) // 1
When a derived value is computed, its computing function is passed two arguments:
previousValue
the last value returned by the computing functionlastComputedEpoch
the value of the global clock when the previous value was last computed.noteBeware that 'last computed' is not the same as 'last changed', since a value can be recomputed and end up the same as it was before.
lastComputedEpoch
can be used in conjunction with the Signal.getDiffSince
method to retrieve a list of changes, or diffs, since the last time the computing function was invoked.
Diffs don't come free
JavaScript's datatypes don't have built-in support for diffs, so you need to implement this functionality manually.
For this tutorial, let's use immer
, which is a library for working with immutable data.
It has the ability to extract diffs while making changes using its produceWithPatches
function.
Here is an example of an Atom wrapper which uses immer
to capture diffs:
import { Patch, produceWithPatches, enablePatches } from 'immer'
import { Atom, atom } from 'signia'
enablePatches()
class ImmerAtom<T> {
// The second Atom type parameter is the type of the diff
readonly atom: Atom<T, Patch[]>
constructor(name: string, initialValue: T) {
this.atom = atom(name, initialValue, {
// In order to store diffs, we need to provide the `historyLength` argument
// to the atom constructor. Otherwise it will not allocate a history buffer.
historyLength: 10,
})
}
update(fn: (draft: T) => void) {
const [nextValue, patches] = produceWithPatches(this.atom.value, fn)
this.atom.set(nextValue, patches)
}
}
Using diffs in computed
We can use the diffs emitted by our ImmerAtom in our computed
functions.
Let's define an incremental version of Array.map:
import { Draft } from 'immer'
import { RESET_VALUE, withDiff } from 'signia'
function map<T, U>(source: ImmerAtom<T[]>, fn: (value: T) => U): Computed<U[], Patch[]> {
return computed(
source.atom.name + ':mapped',
(prev, lastComputedEpoch) => {
// we need to check whether this is the first time we're running
if (isUninitialized(prev)) {
// if so, just map over the array and return it
return source.atom.value.map(fn)
}
// this is not the first time we're running, so we need to calculate the diff of the source atom
const diffs = source.atom.getDiffSince(lastComputedEpoch)
// if there is not enough history to calculate the diff, this will be the RESET_VALUE constant
if (diffs === RESET_VALUE) {
// in which case we need to start over
return source.atom.value.map(fn)
}
// we have diffs and a previous value
const [next, patches] = produceWithPatches(prev, (draft) => {
// apply the upstream diffs while generating a new set of downstream diffs
for (const patch of diffs.flat()) {
const index = patch.path[0]
if (typeof index !== 'number') {
// this will be array length changes
draft[patch.path[0] as 'length'] = patch.value as number
continue
}
if (patch.op === 'add') {
if (patch.path.length === 1) {
// this is a new item in the array, we need to splice it in and call the map function on it
draft.splice(patch.path[0] as number, 0, fn(patch.value) as Draft<U>)
} else {
// one of the existing items in the array has changed deeply
// let's call the map function on the new value
draft[index] = fn(source.atom.value[index]) as Draft<U>
}
} else if (patch.op === 'replace') {
// one of the existing items in the array has been fully replaced
draft[index] = fn(patch.value) as Draft<U>
} else if (patch.op === 'remove') {
next.splice(index, 1)
}
}
})
// withDiff is a helper function that returns a special value that tells Signia to use the
// provided value and diff
return withDiff(next, patches)
},
{
historyLength: 10,
}
)
}
You're probably thinking: "that's a whole lot of code just to map over an array!"
Alas, incremental logic is much trickier to write than non-incremental logic. But often the payoff is worth it.
The payoff
Let's define a list of names and a computed value that reverses them:
const names = new ImmerAtom('names', ['Steve', 'Alex', 'Lu', 'Jamie', 'Mitja'])
let numReverseCalls = 0
const reversedNames = map(names, (name) => {
numReverseCalls++
return name.split('').reverse().join('')
})
console.log(reversedNames.value) // [ 'evetS', 'xelA', 'uL', 'eimaJ', 'ajtiM' ]
console.log(numReverseCalls) // 5
Now if we push a new name into the list, we can see that the 'reverse' function is only called once more:
names.update((draft) => {
draft.push('David')
})
console.log(reversedNames.value) // [ 'evetS', 'xelA', 'uL', 'eimaJ', 'ajtiM', 'divaD' ]
console.log(numReverseCalls) // 6
And similarly, if we update an existing name, the 'reverse' function is only called once more:
names.update((draft) => {
draft[0] = 'Sunil'
})
console.log(reversedNames.value) // [ 'linuS', 'xelA', 'uL', 'eimaJ', 'ajtiM', 'divaD' ]
console.log(numReverseCalls) // 7
And finally, if we pop a name off the list, the 'reverse' function is not even called!
names.update((draft) => {
draft.pop()
})
console.log(reversedNames.value) // [ 'linuS', 'xelA', 'uL', 'eimaJ', 'ajtiM' ]
console.log(numReverseCalls) // 7
You can play with this example on codesandbox https://codesandbox.io/p/sandbox/festive-https-tdbl4q?welcome=true
The historyLength
option
The historyLength
option is used to tell Signia how many diffs to store for that signal. Each time a value changes, a new diff is stored.
If your history length is too small, Signia will not be able to calculate the diff between the current value and the previous value. In this case, Signia will return the RESET_VALUE
constant instead of the diff.
If your history length is very long, Signia will use more memory.
As a rule of thumb, what you set historyLength
to depends on how frequently the signal is read in relation to how often it changes.
- If it changes about as often as it is read, you can use a very low number.
- If it changes infrequently and is read frequently, you can also use a very low number.
- If it changes frequently and is read infrequently, you should use a higher number.
If you're not sure, I would suggest adding dev-time console.warn
statements for when the RESET_VALUE
constant is encountered. This will tell you that Signia doesn't have enough history to calculate the diff, and you should increase the historyLength
option.
The computeDiff
option
If convenient, you can provide a computeDiff
option along with the historyLength
option when creating atoms or computed signals. Signia will use this so that you don't need to supply the diff when calling Atom.set
or when returning from the compute function.
const names = atom('names', ['Bob', 'Alice'], {
historyLength: 10,
computeDiff: (prev, next) => {
return produceWithPatches(prev, (draft) => {
return next
})[1]
},
})
const startEpoch = names.lastChangedEpoch
names.set(['Bob', 'Abhiti'])
console.log(names.getDiffSince(startEpoch)) // [[ { op: 'replace', path: [1], value: 'Abhiti' } ]]
Testing
Applying incremental diffs correctly takes a lot of care and can be difficult in unexpected ways.
We suggest using generative testing to make sure your incremental logic matches your non-incremental logic.
An easy way to get started is by creating a seeded random number generator and using it to generate random 'update ops' for atoms. You can then run the same updates through both the incremental and non-incremental logic and compare the results.
const seed = Math.random()
test(`using seed ${seed}`, () => {
const rng = new RandomNumberGenerator(seed)
const names = new ImmerAtom('names', [], { historyLength: 10 })
// if you set historyLength to 0 it will force your `map` incremental logic down the RESET_VALUE path
const names_no_diff = new ImmerAtom('names_no_diff', [], { historyLength: 0 })
const updateBoth = (fn: (draft: string[]) => void) => {
names.update(fn)
names_no_diff.update(fn)
}
const reversedNames = map(names, (name) => name.split('').reverse().join(''))
const reversedNames_no_diff = map(names_no_diff, (name) => name.split('').reverse().join(''))
for (let i = 0; i < 1000; i++) {
// getRandomNamesOp implementation left as an exercise for the reader
const op = getRandomNamesOp(names.state.value, rng)
if (op.type === 'add_name') {
updateBoth((draft) => {
draft.splice(op.index, 0, op.name)
})
} else if (op.type === 'remove_name') {
updateBoth((draft) => {
draft.splice(op.index, 1)
})
} else if (op.type === 'update_name') {
updateBoth((draft) => {
draft[op.index] = op.name
})
}
// don't check every time, to allow for some history buffer buildup and overflow
if (rng.random() < 0.1) {
expect(reversedNames.value).toEqual(reversedNames_no_diff.value)
}
}
expect(reversedNames.value).toEqual(reversedNames_no_diff.value)
})
Conclusion
Most complex software systems do something along these lines by necessity, usually ad-hoc. The nice thing about integrating it into Signia is that it's now a first-class citizen and it works seamlessly with other signals. There's no need to worry about cache invalidation or update ordering, everything just works.
At tldraw we use incrementally computed signals for a handful of our core data structures, and it's been a huge win for performance. We're able to keep our canvas snappy and responsive even when we have thousands of shapes.
We also have a rudimentary reactive database based on signia
which makes heavy use of incrementally computed signals for building reactive queries and indexes.
This is an extremely new kind of tool with lots of sharp edges! There are probably lots of ways to improve it and address common problems.
We'd be happy to hear your feedback and suggestions on GitHub or in the Discord channel.