Recently, I read Writing an Interpreter in Go, a great book that walks you through building the Monkey programming language step by step. Since I wasn’t familiar with Go, I decided to implement the interpreter in TypeScript instead, first following the object-oriented approach used in the book, but after I finished, I updated it using a more functional style. I used generators to handle the state of the Lexer and the stream of tokens in the Parser. I did not have to update the Evaluator, because it was always a set of functions.
While I was happy with the result, because I had moved it to a more functional approach, it still had mutable variables. I liked how in the Evaluator I did not have any "global" variable, but I needed to stream the parser, and I needed to be able to peek into that stream before I moved as well.
This led me to explore how to deal with state management in a way that avoids direct mutation.
Functional Programming and State Management
When we work with states, we often face a common challenge: mutability. In an imperative paradigm, states change over time, leading to side effects, harder debugging, and increased complexity.
This is where functional programming (FP) provides a more predictable and safer approach. In FP, pure functions are a fundamental concept. A function is pure if it meets two conditions:
- No side effects: It does not modify any external variable or perform operations like changing global objects, writing to a file, or manipulating the DOM.
- Deterministic output: Given the same inputs, it always returns the same output.
Let’s break this down with an example. Imagine we want to add two numbers, a and b. With imperative programming, you would do something like:
const a = 2;
const b = 3;
console.log(a + b);
But, you can do that using a pure function instead:
const add = (a: number, b: number): number => a + b;
console.log(add(2, 3)); // Always returns 5
However, the difference between this and an impure function is clear when some variables are global and mutable.
let total = 0;
const addToTotal = (value: number) => {
total += value; // Modifies an external variable
return total;
};
console.log(addToTotal(3)); // Returns 3
console.log(addToTotal(3)); // Returns 6 (different output for the same input)
Now, each time we call addToTotal
we are incrementing the total
variable, in a way that is not predictable. This is called a side-effect.
This approach becomes problematic when multiple parts of the code depend on the same state and can modify it without restrictions, leading to race conditions or hard-to-reproduce bugs.
Immutable State and Functional Programming
To avoid these issues, functional programming favors immutable data structures. Instead of modifying the existing state, we return a new version with the applied changes:
const state = { count: 0 };
const incrementState = (state: { count: number }) => {
return { ...state, count: state.count + 1 };
};
const newState = incrementState(state);
console.log(state.count); // 0
console.log(newState.count); // 1
This approach offers several key advantages:
- Predictability: Avoiding mutations, changes are easier to track.
- Simpler debugging: If the state doesn’t change unexpectedly, bugs are easier to identify, and reproduce.
- Optimization with efficient immutable structures: Libraries like Immer or techniques like structural sharing allow efficient updates without copying entire data structures.
If we can’t update the state directly, how do we maintain a shared state that persists in function calls? The short answer is: we don't, at least, not in the traditional sense.
However, if you’ve worked with React, you’ve probably noticed that functional components can store and update state. But how? If functions are stateless, where does that state live?
This brings us to reactive programming, a paradigm that models state changes declaratively, without relying on direct mutation.
What is Reactive Programming?
When we need to share state across an app, keeping it in sync can be tricky. If anything can update it, how do you know what’s changing and when? That’s where Reactive Programming comes in. It follows a declarative paradigm to solve this problem. Instead of treating data as static values that we manually update, we model it as a stream of updates, ensuring predictable state management throughout the app.
At its core, a reactive system allows us to define relationships between different pieces of data, so that when one value changes, everything that depends on it updates accordingly, without requiring explicit, manual intervention.
This paradigm is particularly useful in a wide range of scenarios, such as:
- User interfaces - Efficiently updating components when underlying data changes, avoiding unnecessary re-renders.
- Data processing pipelines - Reactively transforming and filtering streaming data.
- State synchronization - Keeping distributed systems in sync without polling.
- Game development - Managing game state, physics simulations, or AI behaviors.
- Automation & event-driven systems - Triggering workflows dynamically based on changes in data.
Unlike imperative programming reactive programming allows us to define what should happen when data changes, and let the system handle the updates efficiently.
How Reactivity Aligns with Functional Programming
If we check what makes a reactive system really effective, it's very similar to what we need from pure functions and FP. First, you want to avoid direct state mutations. Second, function composition makes it easier to build predictable behaviors. And finally, side effects need to be carefully managed.
This is where concepts like signals, computed values, and reactive effects come into play.
The Rise of Signals in Modern JavaScript
One of the most widely adopted patterns in modern reactive systems is the Signal. A signal is a special kind of reactive variable that automatically tracks its dependencies and updates whenever the values it relies on change. Unlike traditional event-driven models, signals offer a fine-grained and efficient way to propagate changes.
Signals have become so popular that many frameworks, like Angular, Solid, Svelte, and Preact, have adopted their implementations. Because of this, the concept of signals is now being considered for standardization in JavaScript. The TC39 committee, responsible for evolving JavaScript, has an active proposal to introduce signals as a built-in language feature, to allow those same frameworks to leverage a better experience and performance when using them.
The fact that Signals are being considered for native support highlights how essential reactive state management has become, not just in UI frameworks but in general programming.
What Comes Next?
With this foundation in place, we are ready to explore how to build a lightweight reactive system from scratch. In this series, we will dive into the core mechanics behind signals and how we can implement them efficiently in TypeScript.
Top comments (0)