This is the second post in a series. Start with the first post if you haven't already.
In the first post I argued that the real problem with modern frontend frameworks isn't performance or verbosity — it's locality of reasoning. You can't look at a line of code and know when or why it will execute. I also laid out the case against signals, composables, and runes specifically: they shift the problem rather than solve it, trading one invisible system for another.
This post is about what the alternative actually looks like in practice — how the entity-centric model holds up beyond a counter example, where it fits, and where it honestly doesn't.
The Component-Centric Assumption
Every mainstream framework is built on the same foundational assumption: the component is the unit of composition.
Logic lives in components. State is owned by components or lifted up from them. The UI is a tree of components, each responsible for its own piece of the world.
This model works well for many applications, especially when UI structure and domain structure align closely. But as complexity grows — particularly in data-heavy tools and dashboards — a structural tension can appear: your business logic becomes entangled with your rendering tree.
State that should logically be global ends up local. Behaviors that should be composable get scattered across components. And when you need multiple independent instances of the same domain concept — say, four charts on a dashboard — you can find yourself working around the component hierarchy rather than describing your domain directly.
Framework ecosystems have evolved good solutions to mitigate this. Vue's composables and Pinia are excellent examples, and React applications often rely on external state managers. These tools solve real problems.
But they still operate within the component-centric model: components remain the primary organizing structure, and reactive machinery determines when logic runs.
The question isn't whether these solutions improve ergonomics. It's whether the component tree is the right abstraction for modeling application behavior in the first place.
The Entity-Centric Model
Inglorious Web starts from a different assumption: entities are the unit of composition, and some entities happen to render.
Your application is a flat collection of entities — plain JavaScript objects with an id and a type. Types define behavior: they're objects whose methods are event handlers.
Most types have a render method, because this is a web UI framework after all. But render is not privileged — it's just another method on the type, and the store doesn't care about it. Some types genuinely don't render at all: a session manager, a WebSocket handler, a background polling loop. They live alongside rendering entities in the same store and respond to events the same way.
The architecture becomes clearer when visualized:
Architecture overview: user actions emit events through
api.notify().
Events are processed deterministically through a queue, dispatched to entity handlers, which update state and trigger rendering.
The event timeline and state snapshots can be inspected using Redux DevTools.
The simplest setup uses autoCreateEntities, which creates one entity per type automatically:
const types = {
taskList: {
create(entity) {
entity.tasks = [];
},
taskAdded(entity, text) {
entity.tasks.push({ id: Date.now(), text, completed: false });
},
taskToggled(entity, id) {
const task = entity.tasks.find((t) => t.id === id);
if (task) task.completed = !task.completed;
},
render(entity, api) {
return html`
<ul>
${entity.tasks.map(
(task) => html`
<li
class=${task.completed ? "completed" : ""}
@click=${() => api.notify(`#${entity.id}:taskToggled`, task.id)}
>
${task.text}
</li>
`,
)}
</ul>
`;
},
},
};
const store = createStore({ types, autoCreateEntities: true });
When you need multiple instances of the same type — the four-charts case — you declare them explicitly instead:
const entities = {
chart1: { type: "chart", data: [ ... ] },
chart2: { type: "chart", data: [ ... ] },
chart3: { type: "chart", data: [ ... ] },
chart4: { type: "chart", data: [ ... ] },
};
const store = createStore({ types, entities });
Each instance gets its own state, managed independently, without lifting state or threading context through component trees.
Notice what's absent from the type definition:
- no
useState - no lifecycle hooks
- no props
The entity holds the data. The type holds the behavior.
create and render live in the same object not because the framework requires it, but because they describe the same entity. You could split them across files and nothing would break — the co-location is for readability, not framework constraints.
Why Testing Gets Simple
Event handlers are plain functions. Testing them requires no DOM, no component tree, and no lifecycle setup.
import { trigger } from "@inglorious/web/test";
const { entity } = trigger(
{ type: "taskList", id: "list", tasks: [] },
taskList.taskAdded,
"Write documentation",
);
expect(entity.tasks).toHaveLength(1);
expect(entity.tasks[0].text).toBe("Write documentation");
Rendering is equally direct because render is a pure function of the entity state:
import { render } from "@inglorious/web/test";
const template = taskList.render(
{ id: "list", type: "taskList", tasks: [{ id: 1, text: "Write documentation", completed: false }] },
{ notify: vi.fn() },
);
const root = document.createElement("div");
render(template, root);
expect(root.textContent).toContain("Write documentation");
This style of testing isn't unique — Redux reducers and well-structured composables can also be tested this way. The difference here is that this is the default architecture of the framework, not something achieved through discipline or additional patterns.
Type Composition: Behavior Without Inheritance
Types are plain objects and compose naturally as arrays. Each entry in the array can be either a behavior object or a decorator function that receives the type composed so far.
const timestamps = {
create(entity) {
entity.createdAt = Date.now();
},
};
const softDeletable = {
softDelete(entity) {
entity.deletedAt = Date.now();
},
};
const types = {
taskList: [timestamps, softDeletable, taskListBase],
};
Decorator functions allow wrapping existing behavior:
const withLogging = (type) => ({
render(entity, api) {
console.log(`Rendering ${entity.id}`);
return type.render(entity, api);
},
});
const types = {
taskList: [taskListBase, withLogging],
};
This pattern handles cross-cutting concerns such as:
- validation
- analytics
- route guards
- permissions
- logging
without inheritance hierarchies or higher-order components.
Cross-Entity Communication
Entities communicate through events.
Every api.notify() call supports three targeting modes:
api.notify("save") // broadcast
api.notify("chart:refresh", payload) // type-targeted
api.notify("#chart1:refresh", payload) // id-targeted
Handlers can emit further events, and all events pass through a queue that processes them in order. This guarantees deterministic execution regardless of how many entities interact.
A common concern with event-driven architectures is that event flows can become difficult to trace. Inglorious Web addresses this by integrating with Redux DevTools, which provides:
- a full event log
- state snapshots
- time-travel debugging
Every notify() call appears in the DevTools timeline, so you can inspect exactly which events fired, in what order, and how the state changed after each one. This makes the event graph visible rather than implicit.
If a render function needs to read another entity's state, api.getEntity() provides a read-only snapshot:
render(entity, api) {
const table = api.getEntity("mainTable");
return html`Showing ${table.filteredRows.length} rows`;
}
In practice this means that when something behaves unexpectedly, you can either:
- search the codebase for the event name, or
- inspect the event log in DevTools.
Either way, the dependency graph lives in your code and tooling rather than inside a reactive runtime.
What the Entity Model Is Not
A few clarifications that come up often:
It's not strict ECS.
Game-engine ECS separates entities, components, and systems into distinct concepts optimized for thousands of homogeneous objects updated every frame. Inglorious Web collapses those concepts into types that carry both data shape and behavior, which better fits typical web UI workloads.
It's not a universal replacement for existing frameworks.
If you're heavily invested in an ecosystem like React or building animation-heavy interfaces, switching architectures has real costs. The entity model targets a specific class of problems: predictable state, debuggable event flows, and complex data-driven interfaces.
The render model is a deliberate tradeoff.
When state changes, the whole tree re-renders and the DOM diff is handled by lit-html. It could seem like naïve re-rendering, but it's not. Fine-grained reactivity systems update only the nodes that changed, which can reduce CPU usage but often increase memory pressure and complexity. Lit-html, in contrast, performs surgical DOM updates while keeping allocations low — deliberately trading fine-grained tracking for a simpler mental model. For many dashboards and data tools the difference is negligible, and reasoning about the UI becomes easier.
How updates propagate.
In reactive component frameworks such as React and Vue, state changes pass through a reactive dependency graph that determines which components re-run.In the entity-centric model, events update the entity store and trigger a single root render, making the UI tree a pure function of the current state.
The Ecosystem
Inglorious Web ships with built-ins that cover the most common needs: a Redux-compatible state manager with DevTools support, lit-html rendering, form handling, table, virtualized list, select, router, declarative SVG charts, an animation library, and SSG with HMR, file-based routing, and Markdown. Because it renders to the real DOM, it works with any web component out of the box — Shoelace, Material Web, Spectrum — no adapter, no wrapper.
The honest gaps: the third-party ecosystem is younger than React's. Event name strings in api.notify() are not yet type-checked. The animation library covers common cases but not everything.
Who This Is For
Inglorious Web is for teams who want their state to be predictable, their tests to be trivial, and their render logic to be legible. It scales from a todo list to a config-driven dashboard precisely because the mental model doesn't change as complexity grows — you keep adding entities and types, not new abstractions.
It resonates strongly with Redux veterans (the three principles are intact, the boilerplate is gone), developers who've worked with ECS in game engines, and engineers building dashboards, HMIs, and data-heavy tools where the component tree metaphor starts feeling forced. It's a harder sell for teams that prioritize deep React ecosystem integration or prefer declarative reactivity as a philosophical stance.
The learning curve is real — not because the framework is complex, but because thinking in entities and events rather than components and hooks is a genuine shift. Most developers who've used Redux and felt "this is almost right" pick it up quickly. For everyone else, it's a one-time investment rather than a continuous tax.
What Comes Next
In the third post, I'll show the numbers: a benchmark running 1000 rows at 100 updates per second across React (naive, memoized, and with RTK) and Inglorious Web (with and without memoization), and a live chart benchmark against Recharts. Performance, bundle size, and what "dramatically smaller optimization surface area" actually looks like when measured.


Top comments (0)