nyrtzi.net

State Propagation

One fundamental problem when constructing software is that of how to propagate changes in state in the context of modularity. State and be either pushed or pulled, and the strength of the coupling between modules can vary from tight to loose. We’re going to look at this from a conceptual point of view here and just focus on making the ideas clear. I’m using the term module loosely here to refer to any unit of code that could or should have a well-defined interface, which defines the protocol for how it talks to the outside and how the outside talks to it, and an internal implementation and state.

Tighly Coupled

What is tight coupling? Tight coupling happens when you can’t use one module without also knowing about another module. This means that changes to one module might require changes to another module as well. This is not good for maintainability since it makes it harder to change one module without affecting others.

One classic example of tight coupling is the banana-gorilla-jungle problem where the problem is that if you need a banana then not only will you get a banana but also a gorilla and a jungle because they’re so strongly bolted onto each other that you can’t just have a banana by itself. In other words the design of the banana was done in such a way that you can’t use it without all the extra complexity that comes with the gorilla and the jungle.

Setting State Directly

In other words just one module reaching directly into the guts of another module to set its state. This is the most tightly coupled way of propagating state changes since the module doing the setting needs to know all the details of the module being changed. This means that any change to the module being changed might require changes to the module doing the changing as well. This is not good for maintainability.

// A needs to know about B
class A {
    constructor(b) {
        this.value = 0;
        this.b = b;
    }
    setValue(v) {
        this.value = v;
        this.b.value = v; // Directly set B's state
    }
}

class B {
  constructor() {
    this.value = 0;
  }
}

let b = new B();
let a = new A(b);
a.b = new B();
a.setValue(42);

If one considers global variables as shared state between all modules then technically this is also an example of tightly coupled state propagation since any module can change the state of any other module by changing the global variable. This is generally considered bad practice since it makes it hard to track down where changes to state are coming from.

let g = 0;

function a() {
    g = 42;
}

function b() {
  console.log(g);
}

a();
b();

Then again indiscriminate use of singleton objects is also a form of global state. They’re basically the object-oriented equivalent of global variables. Not a good pattern either.

Function calls

If you feed the information about a change in state forward then the module that knows of the change must also know about all the modules that need to be informed of the change. This means tight coupling between modules and that’s no good.

// A needs to know about B
class A {
  setValue(v) {
    this.value = v;
    this.b.updateFromA(v);
  }
}

class B {
  updateFromA(v) {
    console.log("B got new value from A:", v);
  }
}

// Update and have state propagate
let a = new A();
a.b = new B();
a.setValue(42);

Compared to directly setting state this is a bit looser since A doesn’t need to reach into B’s guts to set its state directly. However A still needs to know about B and how to call its update method. This means that any change to B’s interface might require changes to A as well. This is still not good for maintainability.

However switching to functions from direct state setting is often the first step towards looser coupling since it allows for more flexibility by hiding the internal details of how B manages its state behind a function interface.

Polling

If one doesn’t want to have modules notifying each other of changes in state then one could have modules regularly poll each other for changes in state. Usually this is done as a last resort since it can be inefficient and if the polling is too infrequent then changes in state might not be noticed in a timely manner.

Loosely Coupled

With loose coupling modules don’t need to know about each other directly or at least not as much. If the object emitting the events and allowing the registration fo listeners follows for example the EventTarget interface as defined in the DOM standard then the listening modules just need a reference to the EventTarget instance to register listeners, to know which events will be emitted and what data will be passed along with each event. This means that as long as both emitter and listener agree on the protocol defined by the events they don’t need to care about each other’s internal details.

Events

One common way to tackle this problem is for example events where one module emits events and others listen for them by registering callbacks. This allows for looser coupling since the module emitting the event doesn’t need to know about the modules listening for it. Instead those interested in changes to state need to know about the module emitting the events so they can register listeners. In principle you could also put something in between to decouple both the source and the sinks of each other. However if no one is listening yet then the event is lost. It also requires you to explicitly register and unregister listeners which can be tedious and error prone.

// A doesn't need to know about B
class A extends EventTarget {
    setValue(v) {
        this.value = v;
        this.dispatchEvent(new CustomEvent('change', { detail: v }));
    }
}

class B {
    constructor(a) {
        a.addEventListener('change', (e) => {
            console.log("B got new value from A:", e.detail);
        });
    }
}

// Update and have state propagate
let a = new A();
let b = new B(a);
a.setValue(42);
// Unregister the listener when no longer needed

Messages

There’s also message bus type of systems nowadays that allow decoupling of senders and receivers of messages. It is a popular pattern in distributed systems but can also be used in single-process systems. The idea is similar to events but instead of registering listeners on specific objects you register them with the message bus itself. This allows for even looser coupling since neither the sender nor the receiver needs to know about each other at all. They just need to know about the message bus and the protocol defined by the messages. There are many different variations on this theme.

let bus = new MessageBus();

class A {
    setValue(v) {
        this.value = v;
        bus.publish('valueChanged', v);
    }
}

class B {
    constructor() {
        bus.subscribe('valueChanged', (v) => {
            console.log("B got new value from A:", v);
        });
    }
}

Channels

Channels (like the ones in Go) are just queues that allow for sending and receiving messages between different parts of a system. They can be used to decouple senders and receivers of messages if you can use both to use the same channel.

ch := make(chan int)

go func() {
    for i := range 10 {
        ch <- i
    }
    close(ch)
}()

for v := range ch {
    fmt.Println("Received value:", v)
}

Stores

One other way deal with this is to use reactive stores. With stores you also subscribe to changes by registering a callback but the store keeps track of its subscribers and runs the registered callbacks both when registering and when there’s a change. This means that even if you subscribe after the store has changed you still get the current value right away instead of having to wait for a change in state or to re-trigger a non-op change. You will still have to unregister the listener when you no longer need it though. It’s not difficult in itself but it can be easy to forget if there’s lots of them.

// A store implementation is generic so the details are not relevant here

// Create a store, subscribe to changes,
// set a new value and see the change propagate.
let store = new Store(0);
let unsubscribe = store.subscribe((v) => {
    console.log("Store value changed to:", v);
});
store.setValue(42);
unsubscribe();

In other words a store is a value wrapped in an interface that allows you to subscribe to changes to that value. This allows for a programming style where state is more fine-grained and changes can be propagated automatically through the system instead of having a whole bunch of modules having complicated event flows between them.

However with stores you’ll end up having state change graphs you need to worry about for example so that you don’t end up creating loops where a change in one store triggers a change in another store which in turn triggers a change in the first store again. You also need to make sure that you register the subscriptions in the right order so that changes propagate in the right order. This can be tedious and error prone as well.

Signals

Signals are like stores but they automatically manage the subscriptions that get registered when you first access the value of the signal. The programming style with signals is not to register callbacks to signals separately but to register global effect functions that get automatically re-run when the signals they access change.

// A signal implementation is generic so the details are not relevant here

// Create some signals and an effect that uses them.
let a = signal(0);
let b = signal(0);
effect(() => {
  console.log("a + b =", a.value + b.value);
});
a.setValue(1);

Derived

As you can see below stores and signals are very similar in how they handle derived state.

let store = new Store(1);
let derivedStore = new DerivedStore(store, (v) => v * 2);
let a = signal(1);
let b = derived(a, (v) => v * 2);

And effects sort of seem very similar to anonymous derived stores but if they do modify a non-reactive value that value does not support subscription. The reason to name a signal in the first place seems to be about the ability to reference it to either read or write its value.

In the end we’re just setting an initial state and constructing a graph of functions that derive new state from existing state with the leaf nodes of the graph producing non-functional side effects that are the outputs of the system.

Potential Issues

Regardless of if you’re just doing straight function calls or using a more elaborate system like events, messages, stores or signals there are some potential issues to be aware of. You can end up with cycles in your state propagation graph which can lead to infinite loops or stack overflows. This is possible both with tightly coupled and loosely coupled systems. If it’s just straight setting of state or function calls then a cycle will likely lead to a stack overflow. With events, messages, stores or signals you might end up with infinite loops that flood a message queue and if the system can’t keep up then it might become unresponsive. That can be hard to debug if your observability tools aren’t up to the task.

Summary

In the end all of these are just tools and techniques to help you manage state propagation. Function calls are by far the simplest but also the most tightly coupled. As with any tool you’ll end up wanting to pick the right tool for the job at hand by comparing the extra complexity required by fancier methods to how much easier they make it for you to write and maintain your code.

Some approaches do make, after all, it easier to reason about state changes and how they propagate through the system while others can help you eliminate entire classes of bugs related to state management. Just be aware of the trade-offs involved and choose wisely.