architecture

Designing a Modular State Engine in JavaScript

2026-02-27 architecture 4 min read
Optimizing Image...

Understanding State vs Procedural Function Calls

Over the past 14 years, my development workflow has relied heavily on procedural-style functions. This approach worked well for many projects, and I still use it when appropriate.

However, for larger, data-driven projects, I've adopted a state-driven engine approach. Here, the application's behavior is guided by the current state of data, rather than by direct DOM manipulation or sequential function calls.

In this model (which I share below), any change to the underlying data immediately propagates through the system, updating the DOM and other outputs predictably. This ensures a clear separation of concerns, making the application easier to maintain, extend, and test.

Moving forward, this state-driven methodology will be a core part of my projects, applied wherever it provides scalability, clarity, and stability.

javascript
let columnFilters = {};
let currentTableData = [];
let sortState = {};

This worked… until it didn't. For my case, this was implicit, fragile, and difficult to extend.

The shift was to consolidate everything into a single source of truth:

javascript
const state = {
  fullData,
  currentData,
  sort,
  filters,
  pagination
};

State is explicit, contained, inspectable in Dev Tools (window.myEngine), and extensible. That was the moment this stopped being "a table script" and became a state engine — and with this shift, it became clear that we needed a structured way to manage how state changes propagate: enter lifecycles.

Creating a Lifecycle

As systems grow, unpredictable state changes become the biggest source of bugs. A lifecycle defines a strict, repeatable sequence of steps that every action passes through. Instead of scattered updates, every interaction flows through a controlled pipeline.

In our table engine, this ensures every action — table column filter, table column sort, or pagination — moves through the same predictable stages:

javascript
refresh() {
  emit("beforeProcess");
  emit("processData");
  emit("afterProcess");
  emit("render");
  emit("afterRender");
}

This defines clear execution phases:

Rendering diagram...

Rule #1: Render Must NEVER Change State ⚠️

Render only reads engine.state. It never mutates it. It may emit afterRender, but it does not change data. Once render mutates state, determinism is gone.

Rule #2: All Behavior Flows Through refresh() ⚡

No plugin should directly call render, mutate state without refresh, or modify the DOM outside the lifecycle. The only allowed flow is:

UI → User Interaction
  ↓
UI Plugin → Plugin handles events
  ↓
Engine State Mutation → Updates state
  ↓
engine.refresh() → Runs lifecycle
  ↓
Lifecycle Pipeline → Triggers hooks
  ↓
Render Output → DOM updated

Core vs UI: The Responsibility Shift

One of the most important architectural decisions is separating Core state logic from UI rendering logic. The Core layer is responsible for transforming data. The UI layer is responsible for displaying it.

Table column filter logic and table column sort logic are not UI behavior. They are core state logic with optional UI adapters.

Anything that changes the dataset, ordering, slice of records, or query state belongs in the Core layer.

Example:

  • User types "Active" in Status column → Core filters dataset to Alice and Carla.
  • User clicks "Name" column header → Core sorts dataset alphabetically.
  • The UI simply displays the dataset returned by Core.

Core Engine Responsibilities

  • Raw dataset storage
  • Table column filter logic
  • Table column sort logic
  • Pagination logic
  • Query state
  • Data transformations
  • Emitting state updates

This must work without a browser.

javascript
engine.setFilter({ status: 'approved' });
engine.setSort({ column: 'lastName', direction: 'asc' });
engine.setPage(2);

The engine recalculates, updates state, and emits events. No DOM involved.

UI Plugin Responsibilities

  • Capturing clicks
  • Rendering tables
  • Displaying pagination controls
  • Binding filter dropdowns
  • Showing sort arrows

The search form is UI. The core should not know it exists.

Example Dataset (Bootstrap Table)

IDNameStatusRole
1AliceActiveAdmin
2BrianInactiveUser
3CarlaActiveManager
4DavidPendingUser

Table column filter logic: Filtering Status = "Active" reduces the dataset to Alice and Carla.

Table column sort logic: Sorting by Name (ascending) reorders rows alphabetically before pagination.

Data Flow Model

Every user action passes through a structured processing chain: UI captures events → Engine updates state → Core plugins process data → UI renders result. This ensures Core logic remains headless and predictable.

Rendering diagram...

Initialization Order Matters

Order is critical. Running pagination before filtering or sorting leads to incorrect pages and dataset inconsistencies. A deterministic lifecycle ensures table column filter logic and table column sort logic always run before pagination.

Rendering diagram...

What This Really Is

This Isn't a random Table script. It's a Modular Data Engine.

When you enforce strict state boundaries and lifecycle discipline, you stop writing UI scripts and start designing systems.

Modularity
Swap UI renderers without touching core logic.
Testability
Core runs without a browser.
Scalability
Add features as plugins.
Clarity
State vs DOM responsibilities are obvious.

Follow these rules and you don't just build features — you build systems.

It's truly a new state of mind.