Skip to content

Minimal library for reactive dataflow programming. Based on topological sort.

License

Notifications You must be signed in to change notification settings

datavis-tech/topologica

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Topologica.js

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.

Installing

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.

API Reference

# 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 If you are typing the dependencies by hand, it makes sense to use the comma-delimited string variant, so that you can easily copy-paste between it and a destructuring assignment (most common case). If you are deriving dependencies programmatically, it makes sense to use the array variant instead.
  • reactive functions If you want to define a reactive function in a self-contained way, for example as a separate module, it makes sense to use the variant where you specify .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.

Usage Examples

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.

Asynchronous Functions

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:

  • Use a property for the promise itself, where nothing depends on this property.
  • Call .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.

Complex Dependency Graphs

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.

Contributing

Feel free to open an issue. Pull requests for open issues are welcome.

Related Work

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.

About

Minimal library for reactive dataflow programming. Based on topological sort.

Resources

License

Stars

Watchers

Forks

Packages

No packages published