DEV Community

Cover image for I'm Done With Magic. Here's What I Built Instead.
Matteo Antony Mistretta
Matteo Antony Mistretta

Posted on

I'm Done With Magic. Here's What I Built Instead.

The JavaScript ecosystem has a magic problem.

Not the fun kind. The kind where you stare at your code, everything looks correct, and something still breaks in a way you can't explain. The kind where you spend forty minutes debugging why your computed() stopped updating, or why an effect fired when you didn't expect it, or why destructuring a store value makes it stop being reactive.

We called it reactivity. We called it signals. We called it runes. And every new name comes with a new layer of invisible machinery running underneath your code, doing things you didn't ask for, breaking in ways you didn't anticipate. The deeper problem 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've been building complex web applications for eighteen years — interactive dashboarding systems, industrial HMI interfaces, config-driven UIs. Those projects are the reason this framework exists: not because they failed, but because I could see exactly how they would age. I got tired of the magic. So I built something without it.

This isn't the Nth framework built out of frustration. It's a deliberate synthesis of ideas that already proved themselves: Redux's three principles, the Entity Component System architecture from game engines, and lit-html's surgical DOM updates. None of these are new. What's new is putting them together and following the logic all the way through.


Once Upon A Time, There Were Three Principles

I really started digging React when they introduced Redux. It revamped Functional Programming concepts as good practices for large-scale systems — proving they belonged in production code, not just CS theory. Three principles made any webapp predictable, debuggable, and testable as never before:

  1. Single Source Of Truth: one state to rule them all.
  2. State Is Read-Only: reference comparisons make re-render decisions trivial and performant.
  3. Changes Through Pure Functions: reducers make logic trivial to reason about.

But things went south. Devs complained that Redux was too verbose, immutable updates were painful, async logic was a hack. Those complaints were valid — the boilerplate was a genuine tax. Enter RTK, which solved real problems: simpler reducers, built-in Immer, sane async thunks. But then it kept going — createAppSlice, builder callback notation, circular dependency nightmares. The question isn't whether Redux needed fixing. It's whether the fixes took things in the right direction. Then the "Single Source Of Truth" dogma started bending entirely: local state here, Context there, Zustand, Jotai, signals. We write less code now, and it just magically works. Well — not for me.


The Problem With Magic

Let me be specific, because "magic is bad" is an easy claim to make and a hard one to defend without evidence.

React re-renders are actually fast — React was right about that. The real problem is that re-renders trigger effects and lifecycle methods. useEffect fires after every matching render, subscriptions re-initialize, derived state recomputes. Invisible dependency arrays silently break when you forget something, and useEffect lists grow into things nobody on the team fully trusts. React's answer? A stable compiler that adds layers of cache automatically. Which means you can have a suboptimal component hierarchy and the compiler will compensate — which is convenient until you need to understand why something broke.

Vue 3 introduced a subtle trap with the Composition API: destructuring a reactive object silently breaks the proxy chain that powers reactivity. Your variable stops updating and you get no warning whatsoever. Vue provides toRefs() specifically to patch this — which proves the point: you now have to manage the integrity of an invisible system on top of writing your actual application. And computed() knows when to recompute by secretly tracking which reactive properties you accessed while it ran, which can produce circular dependencies that only blow up at runtime.

Svelte 5 introduced runes — $state(), $derived(), $effect(). The docs themselves define the word:

rune /ruːn/ noun — A letter or mark used as a mystical or magic symbol.

It's impressive engineering. But unlike JSX — which is a purely syntactic transformation — Svelte's compiler is semantically active: it changes what your code means, not just how it looks. $state() isn't JavaScript with nicer syntax; it's a different programming model that requires the compiler to be correct.

All three are racing in the same direction: more reactivity, more compilation, more invisible machinery. I went the other way.


The Boring Alternative

Inglorious Web is built on one idea: state is data, behavior are functions, rendering is a pure function of state.

No proxies. No signals. No compiler. Just plain JavaScript objects, event handlers, and lit-html's surgical DOM updates. The mental model is a one-time cost, not a continuous tax — you learn it once, and it scales without adding new concepts.

const counter = {
  create(entity) {
    entity.value = 0;
  },

  increment(entity) {
    entity.value++;
  },

  render(entity, api) {
    return html`
      <div>
        <span>Count: ${entity.value}</span>
        <button @click=${() => api.notify(`#${entity.id}:increment`)}>
          +1
        </button>
      </div>
    `;
  },
};
Enter fullscreen mode Exit fullscreen mode

It looks like a hybrid between Vue's Options API and React's JSX. If you prefer either of those syntaxes, there are Vite plugins for both. But the key differences are in what's absent. There are no hooks, no lifecycle methods, no component-level state. create and increment are plain event handlers — closer to RTK reducers than to React methods. The templates are plain JavaScript tagged literals: no new syntax to learn, no compilation step required. Boring doesn't mean verbose — it means every line does exactly what it says.

One deliberate abstraction worth naming: state mutations inside handlers look impure but aren't. The framework wraps them in Mutative — the same structural sharing idea as Immer, but 2–6x faster — so you write entity.value++ and get back an immutable snapshot. That's the only reactive magic in the stack, it's a small and well-understood library, and it's what makes testing trivial.

When state changes, the whole tree re-renders. But lit-html only touches the DOM nodes that actually changed — the same way Redux reducers don't do anything when an action isn't their concern. Re-rendering is cheap. Effects and lifecycle surprises don't exist. The question "why did this effect fire?" is simply impossible to ask, because you can look at any handler and reason about exactly when it runs. And because every state transition is an explicit event, you can grep for every place it's fired — something you cannot do with a reactive dependency graph.


Testing That Actually Makes Sense

In React, testing a component with hooks means setting up a fake component tree and mocking the world around it. In Vue 3, testing a composable means testing impure functions swimming in proxy magic.

In Inglorious Web, testing state logic is this:

import { trigger } from "@inglorious/web/test";

const { entity, events } = trigger(
  { type: "counter", id: "counter1", value: 10 },
  counter.increment,
  5,
);

expect(entity.value).toBe(15);
Enter fullscreen mode Exit fullscreen mode

And testing rendering is equally straightforward:

import { render } from "@inglorious/web/test";

const template = counter.render(
  { id: "counter1", type: "counter", value: 42 },
  { notify: vi.fn() },
);

const root = document.createElement("div");
render(template, root);

expect(root.textContent).toContain("Count: 42");
// snapshot testing works too:
expect(root.innerHTML).toMatchSnapshot();
Enter fullscreen mode Exit fullscreen mode

No fake component tree. No lifecycle setup. No async ceremony. Because render is a pure function of an entity, and a pure function is just a function you call.


The Mental Model Shift

React, Vue, and Svelte are component-centric. The component is the unit. Logic lives in components, state is owned or lifted by them, everything is a tree.

Inglorious Web is entity-centric. Your application is a collection of entities — pieces of state with associated behaviors. Some entities happen to render. Most of the time you don't think about the tree at all.

If you've heard of the Entity Component System (ECS) architecture used in game engines, this will feel familiar — though it's not a strict implementation. Think of it as ECS meets Redux: entities hold data, types hold behavior, and the store is the single source of truth. The practical consequence is that you can add, remove, or compose behaviors at the type level without touching the UI, and you can test state logic in complete isolation from rendering. That's not just less magic — it's a different ontology.


What Comes Next

This is the first post in a series.

In the next post, I'll go deeper into the entity-centric architecture: how types compose, how the ECS lineage maps to real web UI problems, and whether the mental model holds up at scale — from a TodoMVC to a config-driven industrial HMI. I'll also be honest about the ecosystem, the tradeoffs, and where the framework fits and where it doesn't.

In the third post, I'll show the numbers: a benchmark running 1000 rows at 100 updates per second, comparing React (naive, memoized, and with RTK), and a live chart benchmark against Recharts. Performance, bundle size, and what "dramatically smaller optimization surface area" actually looks like in practice.

The ecosystem is moving toward more magic. I'm moving the other way.

Docs · Repo

Top comments (2)

Collapse
 
trinhcuong-ast profile image
Kai Alder

Really interesting take. The Vue destructuring trap you mentioned has bitten me more times than I'd like to admit - spent a solid hour once wondering why a composable's return values weren't updating before realizing I'd destructured the reactive object.

The ECS approach is what caught my attention though. I've worked on a couple game-adjacent projects and the entity/component pattern is genuinely underused in web dev. The way you're separating state from behavior from rendering feels like it'd scale really well for dashboards and config-driven UIs where the component tree metaphor starts to feel forced.

My one question: how does this handle cross-entity communication? Like if entity A needs to react to changes in entity B - is that all through the event/notification system? Curious how that looks in practice when you've got 20+ entity types interacting.

Collapse
 
iceonfire profile image
Matteo Antony Mistretta

Thanks Kai, glad it resonated! The Vue destructuring story is exactly the kind of thing that's hard to explain until it's happened to you.

Cross-entity communication is all through the event system, with three targeting modes:

api.notify("someEvent", payload)         // broadcast — every entity with that handler reacts
api.notify("dashboard:refresh", payload) // type-targeted — only "dashboard" entities
api.notify("#chart1:refresh", payload)   // id-targeted — one specific entity
Enter fullscreen mode Exit fullscreen mode

Handlers can also fire further events via api.notify() — which might sound like it could spiral, but every event goes through a queue that processes them in order, so the flow stays deterministic no matter how many entities are interacting.

If a render function really needs to peek at another entity's state, api.getEntity("entityId") gives you a read-only snapshot:

render(entity, api) {
  const table = api.getEntity("mainTable");
  return html`Showing ${table.filteredRows.length} rows`;
}
Enter fullscreen mode Exit fullscreen mode

And when you need to understand why something happened, the store is compatible with Redux DevTools — full event history, state inspection, and time-travel debugging out of the box.