DEV Community

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

Posted on • Edited 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 (14)

Collapse
 
ben profile image
Ben Halpern

magic

Collapse
 
iceonfire profile image
Matteo Antony Mistretta

That was perfect 🤣

Collapse
 
appurist profile image
Paul / Appurist

You had me interested until "When state changes, the whole tree re-renders. But lit-html only touches the DOM nodes that actually changed". This problem has been solved with signals, popularized by SolidJS. Signals are also very simple, a function to get, and a function to set. I liked most of the rest of this, but I feel like this is a step backwards. It's a postive step from React, but that's a horrible pile of kludges because it was never intended to be a framework, just a library, and even Angular uses signals now.

I'm not saying signals are the best solution that exists, but they enable only updating the very specific part of the DOM that changed. They are also separate from the concept of components, and can even be global. They are just data, getter and setter so that can live anywhere, and very very easy to debug if something is weird.

Personally I think frameworks went off the rails at least a decade ago and we're starting to pull out of the nose dive. From this article, I think you grok the problem, mostly at least. If you read the short "The kitchen sink problem" paragraph in nuejs.org/docs/why-nue, it's spelled out in a concise pragmatic way.

After reading that, I'm back to HTML+CSS+JS but sometimes I'm using SolidJS as a light, superfast signals store for reactive data. Funny how everything old is new again. Modern CSS is surprisingly complete and feature-rich now: CSS variables, nested rules, :has, etc. Those using Tailwind or something similar don't get it yet. They probably will eventually. Those still using React definitely don't get it. I think you get it fairly well, but haven't quite taken it all the way yet. Thank you for posting this, it's another experienced developer saying "hold on a minute, let's think about this in the Big Picture." We need a lot more articles like this one to get people to rethink things more.

Collapse
 
iceonfire profile image
Matteo Antony Mistretta

Thank you for the feedback! I really appreciate it.

You are right: signals are great, and fine-grained reactivity is genuinely faster in theory - on paper, at least. In fact, a re-render of the whole tree for the update of a single component every once in a while sounds very counter-intuitive.

But I ran benchmarks on whole-tree re-rendering with lit-html and the results were surprising: you get clean 120 FPS just like with signals, while wasting ~100ms of CPU every 10 seconds. For the vast majority of apps, that's not a problem worth solving at the cost of architectural complexity.

Here's the benchmark if you want to dig in.

I'd rather have a simple, predictable rendering model and pay a cost nobody notices than optimise for a problem I don't have. Which, funnily enough, is the same argument as the rest of the post.

Collapse
 
appurist profile image
Paul / Appurist

Good feedback, yourself there! Two things. I don't find signals add architectural complexity, in any way. They just behave like variables, accept there are get/set accessors. They can go anywhere, are completely separate from components (one of the key points in the post above?) and thus I find them effectively cost-free. The benefit is that there is no render loop. At all. If nothing changes, no code runs. Only when it does change, does the affected code run. It's optimal by default, without something like lit. That 100ms every 10 seconds is still 1% of your CPU being thrown away when it could just be idle. I do agree that in the pragmatic world, none of that matters. The solution in your article is still a good choice and a good combination. I'm just a bit of an idealist when designing something new, especially in 2026 when we no longer need render loops.

Thread Thread
 
iceonfire profile image
Matteo Antony Mistretta

The complexity that signals add in my opinion is a hidden dependency graph that could become hard to trace in large-scale systems. If you look at the benchmark, Inglorious Web wastes a bit of CPU time but the JS heap is very low, while Svelte uses less CPU but has a higher heap allocation. So in the end it seems like we're boiling down to the old caching problem: waste time to save space, or waste space to save time.

Collapse
 
iugorsette profile image
Iugor Sette Pereira

I’ve noticed the same pattern: as abstractions get more “magical”, debugging cost increases even if developer experience initially improves. Predictability and explicit state transitions are underrated in modern frontend architecture.
Maybe is the why I disclaimer clean arch, too much abstractions

Collapse
 
iceonfire profile image
Matteo Antony Mistretta

Thank you for your feedback! I'm glad you feel the same way.

Interesting take on clean architectures: personally, I think you can achieve a clean architecture without the need for magic. That's what I was trying to do with Inglorious Store: boring is sometimes better than clever. And clean code should not coincide with over-engineered code.

Collapse
 
iugorsette profile image
Iugor Sette Pereira

I see clean code and clean architecture as different things. The clean code not, but clean architecture to me is overrated, and sometimes is contradictory to clean code. Exemple: build things thats could be modified. YAGNI

Thread Thread
 
iceonfire profile image
Matteo Antony Mistretta

Fair distinction! I think the intent behind clean architecture is sound — keep dependencies sane, separate concerns — but the prescribed implementation is often overkill. Interfaces and abstraction layers for a CRUD app nobody asked for.

Inglorious Store tries to capture the intent without the ceremony. Structure through convention, not boilerplate.

Thread Thread
 
iugorsette profile image
Iugor Sette Pereira

I’ll definitely explore it more and study the approach from that pov.

Collapse
 
matthewhou profile image
Matthew Hou

"Magic" frameworks are a tradeoff: you pay cognitive debt instead of upfront complexity.

When things work, magic is fast. When things break — and they always eventually break — you're debugging through layers you didn't write and don't understand. I've had 3am incidents that would've taken 20 minutes to fix in explicit code and took 3 hours in a "magic" setup because I couldn't see what was actually happening.

The shift you described toward explicit, understandable systems is the right instinct. The question is usually: how much magic can you afford given your team's ability to debug it? That answer varies a lot by context.

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.