Devlog: dependency graph as architecture

Time for devlog #4! Last week's was here.

The visual, live development environment I've been working on could not have been built without Gooey, my frontend web framework.

Let's get into how it works.

Gooey: a dependency graph engine

Though it calls itself a "focused and flexible frontend web framework," Gooey is really a dependency graph engine.

In this dependency graph, nodes are either pieces of data or calculations, and the edges represent the flow of this data.

To demonstrate, here's a little interactive tool to visualize the angle of a line segment:

The full code can be found here.

import Gooey, { calc, field, mount, ref } from '@srhazi/gooey';
import type { Component, Field } from '@srhazi/gooey';

const NumericInput: Component<{
    value: Field<number>;
    id?: string;
    type: string;
    min: string;
    max: string;
}> = ({ value, ...rest }) => (
    <input
        {...rest}
        value={value}
        on:input={(e, el) => value.set(el.valueAsNumber)}
    />
);

const Visualization: Component = (props, { onMount }) => {
    const canvasRef = ref<HTMLCanvasElement>();
    let ctx: CanvasRenderingContext2D | null = null;

    // Input data, just X & Y coordinates
    const x = field(40);
    const y = field(30);

    // Derived data, calculations that show angle & degrees
    const angle = calc(() => {
        const baseAngle = Math.atan(y.get() / x.get());
        if (x.get() < 0) {
            return baseAngle + Math.PI;
        }
        if (y.get() < 0) {
            return baseAngle + 2 * Math.PI;
        }
        return baseAngle;
    });
    const degrees = calc(() => (angle.get() * 360) / (2 * Math.PI));

    // A calculation which is re-evaluated when dependencies change, but doesn't return anything.
    // This can be used to subscribe to multiple things at once.
    const render = calc(() => {
        update({
            x: x.get(),
            y: y.get(),
            angle: angle.get(),
        });
    });

    onMount(() => {
        if (canvasRef.current) {
            ctx = canvasRef.current.getContext('2d');
        }
        // By subscribing to our render calculation, it becomes "alive" and is recalculated when the x/y/angle changes
        return render.subscribe(() => {});
    });

    const update = ({
        x,
        y,
        angle,
    }: {
        x: number;
        y: number;
        angle: number;
    }) => {
        if (!ctx) {
            return;
        }
        ctx.fillStyle = '#F4F2F4';
        ctx.rect(0, 0, ctx.canvas.width, ctx.canvas.height);
        ctx.fill();
        ctx.save();
        ctx.scale(ctx.canvas.width / 200, ctx.canvas.height / 200);
        ctx.translate(100, 100);

        // Draw outline for angle
        ctx.beginPath();
        ctx.save();
        ctx.strokeStyle = '#C0C000';
        ctx.setLineDash([2.5, 2.5]);
        ctx.arc(0, 0, 20, 0, 2 * Math.PI, angle > 0);
        ctx.stroke();
        ctx.restore();

        // Draw orange arc for angle
        ctx.beginPath();
        ctx.strokeStyle = '#C0C000';
        ctx.fillStyle = '#808000';
        ctx.arc(0, 0, 20, 0, -angle, angle > 0);
        ctx.stroke();
        ctx.lineTo(0, 0);
        ctx.fill();

        // Draw red line for X axis
        ctx.beginPath();
        ctx.strokeStyle = '#800000';
        ctx.fillStyle = '#C00000';
        ctx.moveTo(0, 0);
        ctx.lineTo(0 + x, 0);
        ctx.stroke();

        // Draw green line for Y axis
        ctx.beginPath();
        ctx.strokeStyle = '#008000';
        ctx.fillStyle = '#00C000';
        ctx.moveTo(0 + x, 0);
        ctx.lineTo(0 + x, 0 - y);
        ctx.stroke();

        // Draw blue line for hypotenuse
        ctx.beginPath();
        ctx.strokeStyle = '#0000C0';
        ctx.fillStyle = '#000080';
        ctx.moveTo(0, 0);
        ctx.lineTo(0 + x, 0 - y);
        ctx.stroke();

        // Red X point
        ctx.beginPath();
        ctx.strokeStyle = '#800000';
        ctx.fillStyle = '#C00000';
        ctx.arc(0 + x, 0, 4, 0, 2 * Math.PI);
        ctx.stroke();
        ctx.fill();

        // Green Y point
        ctx.beginPath();
        ctx.strokeStyle = '#008000';
        ctx.fillStyle = '#00C000';
        ctx.arc(0 + x, 0 - y, 4, 0, 2 * Math.PI);
        ctx.stroke();
        ctx.fill();

        // Blue center point
        ctx.beginPath();
        ctx.strokeStyle = '#0000C0';
        ctx.fillStyle = '#000080';
        ctx.arc(0, 0, 4, 0, 2 * Math.PI);
        ctx.stroke();
        ctx.fill();

        ctx.restore();
    };

    return (
        <div>
            <style>
                {`
.triangle-canvas {
    position: relative;
    cursor: crosshair;
    width: 400px;
    height: auto;
}
.triangle-canvas-controls {
    display: grid;
    padding: 16px;
    gap:  16px;
    grid-template-columns: auto 1fr;
}
.triangle-angle-input {
    text-align: right;
}
        `}
            </style>
            <canvas
                class="triangle-canvas"
                ref={canvasRef}
                width="800"
                height="800"
                on:mousemove={(e, el) => {
                    const box = el.getBoundingClientRect();
                    const mouseX =
                        2 * ((e.clientX - box.left) / box.width - 0.5);
                    const mouseY =
                        2 * ((e.clientY - box.top) / box.height - 0.5);
                    x.set(Math.round(mouseX * 1000) / 10);
                    y.set(Math.round(-mouseY * 1000) / 10);
                }}
                on:touchmove={(e: TouchEvent, el) => {
                    if (e.targetTouches[0]) {
                        e.preventDefault();
                        const box = el.getBoundingClientRect();
                        const touchX =
                            2 *
                            ((e.targetTouches[0].clientX - box.left) /
                                box.width -
                                0.5);
                        const touchY =
                            2 *
                            ((e.targetTouches[0].clientY - box.top) /
                                box.height -
                                0.5);
                        x.set(Math.round(touchX * 10000) / 10);
                        y.set(Math.round(-touchY * 1000) / 10);
                    }
                }}
            />
            <fieldset class="triangle-canvas-controls">
                <legend>Controls</legend>
                <label for="x-value">X Value</label>
                <NumericInput
                    id="x-value"
                    type="number"
                    value={x}
                    min="-100"
                    max="100"
                />
                <span />
                <NumericInput type="range" value={x} min="-100" max="100" />
                <label for="y-value">Y Value</label>
                <NumericInput
                    id="y-value"
                    type="number"
                    value={y}
                    min="-100"
                    max="100"
                />
                <span />
                <NumericInput type="range" value={y} min="-100" max="100" />
                <label for="angle">Angle</label>
                <input
                    class="triangle-angle-input"
                    id="angle"
                    type="text"
                    value={calc(() => `${angle.get().toFixed(3)} radians`)}
                    readonly
                />
                <label for="degrees">Degrees</label>
                <input
                    class="triangle-angle-input"
                    id="degrees"
                    type="text"
                    value={calc(() => `${degrees.get().toFixed(3)} degrees`)}
                    readonly
                />
            </fieldset>
        </div>
    );
};

const angleViz = document.getElementById('angleViz');
if (angleViz) {
    mount(angleViz, <Visualization />);
}

The most important part of that code is its data model.

It is built from Fields and Calculations, which form a dependency graph.

const Visualization: Component = (props, { onMount }) => {
  // ...canvas code omitted...

  // Input data, just X & Y coordinates
  const x = field(40);
  const y = field(30);

Here we have a Visualization component—a render function that creates 2 fields: x and y.

Fields are like containers, they hold a piece of data that be swapped out over time.

  // Derived data, calculations that show angle & degrees
  const angle = calc(() => {
    const baseAngle = Math.atan(y.get() / x.get());
    if (x.get() < 0) {
      return baseAngle + Math.PI;
    }
    if (y.get() < 0) {
      return baseAngle + 2 * Math.PI;
    }
    return baseAngle;
  });

  const degrees = calc(() => (angle.get() * 360) / (2 * Math.PI));

And here we have two calculations defined: angle and degrees.

Calculations are like derived fields, they produce a value that depends the data read.

Both fields and calculations live on the dependency graph as nodes.

When a calculation's function reads a piece of data, an edge is added to the dependency graph going from the node read to the node doing the reading.

  • When angle calls y.get() and x.get(), two edges are added: y -> angle & x -> angle
  • When degrees calls angle.get(), one edge is added: angle -> degrees

Calculations don't always need to return a value:

  // A calculation which is re-evaluated when dependencies change, but doesn't return anything.
  // This can be used to subscribe to multiple things at once.
  const render = calc(() => {
    update({
      x: x.get(),
      y: y.get(),
      angle: angle.get(),
    });
  });

Here, a render calculation reads some data from the fields and calculations it depends on (x, y, and angle), and passes their values to an update() function which does the work of drawing to the canvas.

This may feel a bit like an useEffect in React, except you don't need to list out all the dependencies.

If we draw this dependency graph out as a diagram, we get this visual:

angleviz x x angle angle x->angle render render x->render y y y->angle y->render degrees degrees angle->degrees angle->render
Fields are rectangles; Calculations are ellipses; Edges show the flow of data

Processing the dependency graph

When Gooey sees that an authoritative piece of data has changed (i.e. x.set(10)), the corresponding node is marked as "dirty" and an asynchronous task is scheduled to process the graph (via queueMicrotask).

Dirty nodes are processed in topological order and may mark their downstream neighbors as "dirty".

When a calculation is processed after being marked as dirty, its function is called again. If its return value differs, it marks its downstream neighbors as dirty.

Always in order

Gooey always processes the graph in topological order—even if nodes and edges in the graph change as nodes are processed!

This is tricky, and may cause a node to be processed multiple times. For example, a calculation's function may be called multiple times if it gains a dependency by reading a new node which happens to depend on a dirty value.

To manage this in-order processing, Gooey maintains a linear ordering of all nodes in its dependency graph. Think of this ordering as an array of nodes, where all of the edges go left-to-right.

If an edge is added which goes in the opposite direction, the ordering is now wrong. To correct this, the out-of-order slice of that array is reordered to maintain the left-to-right invariant.

Always memoized

Because the dependency graph is processed following the flow of data, calculations can be stateful: they hold onto their function's return values until marked as dirty.

This means that any calc() encountered in Gooey is automatically memoized based on the data it has read. It's a bit like a useMemo in React, except you also don't need to list out all the dependencies.

Not just single-value fields

Collections in Gooey are like arrays of Fields: each item in the collection lives in the dependency graph as a node.

Unlike fields, collections may be subscribed to in a more array-oriented way. Collections have a subscribe method for observing changes.

Similar to how calculations are the derived counterpoint to authoritative fields, views are the derived counterpoint to authoritative collections.

  • Want a derived collection where each item is transformed? Use .mapView()
  • Want a derived collection where each item is conditionally present? Use .filterView()
  • Want both? Use .flatMapView()

Binding nodes to UI

Gooey also allows you to bind nodes in the dependency graph directly to the DOM via JSX.

In our visualization, we can see this in how the input text boxes have their value property bound directly to the result of a calculation:

<input
    class="triangle-angle-input"
    id="angle"
    type="text"
    value={calc(() => `${angle.get().toFixed(3)} radians`)}
    readonly
/>

And if JSX is returned by a calculation, it will be rendered to the DOM in place and replaced by its contents when the calculation is processed.

A dependency graph as an architecture

If all of your application's data is built from these fields and calculations (and collections, etc...), then your application has a clear defined separation between authoritative data and derived data.

A funny thing happens when you start structuring your application this way.

Since this state can live anywhere in your application, you stop needing rules around the structure and subscriptions of your "data store".

You can model your application's state naturally, using field(), calc(), and collection(). And regardless of where that state is placed or how it's accessed, it will be automatically updated and memoized efficiently.

For example, the development environment I'm building roughly models a JavaScript module.

A screenshot showing a module with a few cells

There's a ModuleModel class which holds its name bindings in a collection of ModuleNameBinding instances. These name bindings have fields representing the name (so names can change without impacting the value), and value at that binding.

class ModuleNameBinding {
    name: Field<string>;
    value: CellModel | FunctionModel | ...;

    // ... implementation code ...
}

class ModuleModel {
    bindings: Collection<ModuleNameBinding>;

    // ... implementation code ...
}

This allows name bindings to be dynamically added/removed from the module, and bindings to be renamed / their value changed.

Similarly, "Cells" in the application are instances of the CellModel class. They hold their dynamic properties in a collection of property bindings which hold AtomModel class instances, which are "dynamic property objects" which contain source code that is evaluated to produce their result.

class AtomModel {
  sourceCode: Field<string>;
  value: Calculation<any>; // sourceCode evaluation result

  // ... implementation code ...
}

class CellModelProperty {
  name: Field<string>;
  value: AtomModel;

  // ... implementation code ...
}

class CellModel {
  properties: Collection<AtomModel>

  // ... implementation code ...
}

Since calculations automatically add dependencies when they're read, there's no need to worry about adding subscriptions to this data. If the underlying data changes, the Calculation will be re-evaluated.

This is even true for AtomModel which has user-provided source code. If the evaluation of that source code happens to read any data in the dependency graph, it will be re-evaluated if (and only if) the dependencies change.

This gives a ton of flexibility to the data model over time. Since calculations don't need to worry about subscribing to changes, fields can change freely over time and calculations don't need to change at all!

It's a lot more forgiving than other state management solutions, which can be brittle and hard to change over time.