Minimal library for reactive dataflow programming. Based on topological sort.
A library for reactive programming. Weighs 1KB minified.
This library provides an abstraction for reactive data flows. This means you can declaratively specify a dependency graph, and the library will take care of executing only the required functions to propagate changes through the graph in the correct order. Nodes in the dependency graph are named properties, and edges are reactive functions that compute derived properties as functions of their dependencies. The order of execution is determined using the topological sorting algorithm, hence the name Topologica.
Topologica is primarily intended for use in optimizing interactive data visualizations created using D3.js and a unidirectional data flow approach. The problem with using unidirectional data flow with interactive data visualizations is that it leads to unnecessary execution of heavyweight computations over data on every render. For example, if you change the highlighted element, or the text of an axis label, the entire visualization including scales and rendering of all marks would be recomputed and re-rendered to the DOM unnecessarily. Topologica.js lets you improve performance by only executing heavy computation and rendering operations when they are actually required. It also allows you to simplify your code by splitting it into logical chunks based on reactive functions, and makes it so you don’t need to think about order of execution at all.
Why use topological sorting? To avoid inconsistent state. In the following data flow graph, propagation using breadth-first search (which is what Model.js and some other libraries use) would cause e
to be set twice, and the first time it would be set with an inconsistent state (as occurs with “glitches” in reactive programming). Using topological sorting for change propagation guarantees that e
will only be set once, and there will never be inconsistent states.
The tricky case, where breadth-first propagation fails but topological sorting succeeds.
You can install via NPM like this:
npm install --save-dev topologica
Then import it into your code like this:
import Topologica from 'topologica';
You can also include the library in a script tag from Unpkg, like this:
<script src="https://unpkg.com/[email protected]/dist/topologica.min.js"></script>
This script tag introduces the global Topologica
.
# Topologica(reactiveFunctions)
Constructs a new data flow graph with the given reactiveFunctions argument, an object whose keys are the names of computed properties and whose values are reactive functions. By convention, the variable name dataflow
is used for instances of Topologica, because they are reactive data flow graphs.
const dataflow = Topologica({ fullName });
A reactive function accepts a single argument, an object containing values for its dependencies, and has an explicit representation of its dependencies. A reactive function can either be represented as a function with a dependencies property, or as an array where the first element is the function and the second element is the dependencies. Dependencies can be represented either as an array of property name strings, or as a comma delimited string of property names.
function | array | |
---|---|---|
Dependencies array | const fullName =
({firstName, lastName}) =>
${firstName} ${lastName};
fullName.dependencies =
['firstName', 'lastName']; |
const fullName = [
({firstName, lastName}) =>
${firstName} ${lastName},
['firstName', 'lastName']
]; |
Dependencies string | const fullName =
({firstName, lastName}) =>
${firstName} ${lastName};
fullName.dependencies =
'firstName, lastName'; |
const fullName = [
({firstName, lastName}) =>
${firstName} ${lastName},
'firstName, lastName'
]; |
This table shows all 4 ways of defining a reactive function, each of which may be useful in different contexts.
.dependencies
on a function (most common case). If you want to define multiple smaller reactive functions as a group, for example in the statement that constructs the Topologica instance, then it makes sense to use the more compact two element array variant.# dataflow.set(stateChange)
Performs a shallow merge of stateChange
into the current state, and propages the change through the data flow graph (synchronously) using topological sort. You can use this to set the values for properties that reactive functions depend on. If a property is not included in stateChange
, it retains its previous value.
dataflow.set({
firstName: 'Fred',
lastName: 'Flintstone'
});
The above example sets two properties at once, firstName
and lastName
. When this is invoked, all dependencies of fullName
are defined, so fullName
is synchronously computed.
If a property in stateChange
is equal to its previous value using strict equality (===
), it is not considered changed, and reactive functions that depend on it will not be invoked. You should therefore use only immutable update patterns when changing objects and arrays.
If a property in stateChange
is not equal to its previous value using strict equality (===
), it is considered changed, and reactive functions that depend on it will be invoked. This can be problematic if you’re passing in callback functions and defining them inline in each invocation. For this case, consider defining the callbacks once, and passing in the same reference on each invocation (example), so that the strict equality check will succeed.
# dataflow.get()
Gets the current state of all properties, including derived properties.
const state = dataflow.get();
console.log(state.fullName); // Prints 'Fred Flintstone'
Assigning values directly to the returned state
object (for example state.firstName = 'Wilma'
) will not trigger reactive functions. Use set instead.
External running examples:
You can define reactive functions that compute properties that depend on other properties as input. These properties exist on instances of Topologica
, so in a sense they are namespaced rather than free-floating. For example, consider the following example where b
gets set to a + 1
whenever a
changes.
// First, define a function that accepts an options object as an argument.
const b = ({a}) => a + 1;
// Next, declare the dependencies of this function as an array of names.
b.dependencies = ['a'];
// Pass this function into the Topologica constructor.
const dataflow = Topologica({ b });
// Setting the value of a will synchronously propagate changes to B.
dataflow.set({ a: 2 });
// You can use dataflow.get to retreive computed values.
assert.equal(dataflow.get().b, 3);
When a changes, b gets updated.
Here’s an example that assigns b = a + 1
and c = b + 1
.
const b = ({a}) => a + 1
b.dependencies = ['a'];
const c = ({b}) => b + 1;
c.dependencies = ['b'];
const dataflow = Topologica({ b, c }).set({ a: 5 });
assert.equal(dataflow.get().c, 7);
Note that set
returns the Topologica
instance, so it is chainable.
Here, b is both an output and an input.
Here’s an example that uses an asynchronous function. There is no specific functionality in the library for supporting asynchronous functions differently, but this is a recommended pattern for working with them:
.set
asynchronously after the promise resolves.const dataflow = Topologica({
bPromise: [
({a}) => Promise.resolve(a + 5).then(b => dataflow.set({ b })),
'a'
],
c: [
({b}) => {
console.log(b); // Prints 10
},
'b'
]
});
dataflow.set({ a: 5 });
Asynchronous functions cut the dependency graph.
The dependency graphs within an instance of Topologa can be arbitrarily complex directed acyclic graphs. This section shows some examples building in complexity.
Here’s an example that computes a person’s full name from their first name and and last name.
const fullName = ({firstName, lastName}) => `${firstName} ${lastName}`;
fullName.dependencies = 'firstName, lastName';
const dataflow = Topologica({ fullName });
dataflow.set({ firstName: 'Fred', lastName: 'Flintstone' });
assert.equal(dataflow.get().fullName, 'Fred Flintstone');
Now if either firstName or lastName
changes, fullName
will be updated (synchronously).
dataflow.set({ firstName: 'Wilma' });
assert.equal(dataflow.get().fullName, 'Wilma Flintstone');
Full name changes whenever its dependencies change.
Here’s the previous example re-written to specify the reactive function using a two element array with dependencies specified as a comma delimited string. This is the form we’ll use for the rest of the examples here.
const dataflow = Topologica({
fullName: [
({firstName, lastName}) => `${firstName} ${lastName}`,
'firstName, lastName'
]
});
You can use reactive functions to trigger code with side effects like DOM manipulation.
const dataflow = Topologica({
fullName: [
({firstName, lastName}) => `${firstName} ${lastName}`,
'firstName, lastName'
]
fullNameText: [
({fullName}) => d3.select('#full-name').text(fullName),
'fullName'
]
});
assert.equal(d3.select('#full-name').text(), 'Fred Flintstone');
Here’s the tricky case, where breadth-first or time-tick-based propagation fails (e.g. when
in RxJS) but topological sorting succeeds.
const dataflow = Topologica({
b: [({a}) => a + 1, 'a'],
c: [({b}) => b + 1, 'b'],
d: [({a}) => a + 1, 'a'],
e: [({b, d}) => b + d, 'b, d']
});
dataflow.set({ a: 5 });
const a = 5;
const b = a + 1;
const c = b + 1;
const d = a + 1;
const e = b + d;
assert.equal(dataflow.get().e, e);
For more examples, have a look at the tests.
Feel free to open an issue. Pull requests for open issues are welcome.
This library is a minimalistic reincarnation of ReactiveModel, which is a re-write of its precursor Model.js.
The minimalism and synchronous execution are inspired by similar features in Observable.
Similar initiatives:
See also this excellent article State management in JavaScript by David Meister.