How to Build a Lightweight Framework for Internal Tools

For senior engineers building internal tools · Based on Glitchy Devs Build-Your-Own Frontend Framework

// TL;DR

Senior engineers who need a tiny, fully-understood frontend layer for internal tools or embedded widgets can use this methodology instead of pulling in a heavy framework. You build a lightweight framework in pure JavaScript with tagged-template templating, Snabbdom virtual DOM diffing, and reactive mapped methods — no build-time JSX, minimal dependencies, and every abstraction under your control. It's ideal when React's bundle size and ecosystem overhead aren't justified but you still want declarative, reactive components with scoped DOM updates you can audit line by line.

When does a custom lightweight framework beat React for internal tools?

For internal dashboards, embedded widgets, or admin panels where bundle size, auditability, and zero black-box behavior matter more than ecosystem breadth, a hand-built framework is a legitimate engineering choice. React and Vue bring enormous ecosystems — but also runtime weight, transitive dependencies, and abstractions your team can't fully inspect. This methodology gives you declarative, reactive components with only Snabbdom as a dependency, and every layer is code you own and understand.

The guiding principle — no black boxes — is exactly what you want when a tool has to be maintained long-term by a small team without deep framework specialists.

What does the architecture look like?

A component is always three co-located pieces: a template function (HTML structure via tagged template literals), a methods object (pure state mutations), and an initial state object. You pass them to `createComponent`, which returns a reactive render function.

Under the hood, `createElement(tagName)` is a higher-order function producing Snabbdom virtual nodes via the `h` function. `createComponent` maintains a `state` variable and a `previous` VDOM, and wraps each method into a mapped method that updates state, re-evaluates the template, and calls `patch(previous, nextNode)` to update only what changed. This is your reconciliation layer — small, explicit, and debuggable.

How do you keep state predictable at scale?

Enforce the discipline that state is never mutated in place. Every mutation function has the signature `(state, ...params) => ({ ...state, changed: value })`, returning a new object via the spread operator. This keeps transformations traceable — critical when multiple engineers touch the same internal tool over time and you need predictable, reviewable state changes.

For tools with heavier interaction, plan the documented extensions early: batched updates to collapse multiple mutations into a single patch cycle, memoization to skip unchanged template re-evaluation, and virtual scrolling for large data tables — common in internal dashboards.

How do you handle events and side effects cleanly?

Use event factory functions like `onClick(fn)` returning `{ click: fn }`, and include Snabbdom's eventlisteners module in `snabbdom.init([eventListenersModule])`. In `createElement`, detect event objects and route them to the `on` property of the `h` data argument. Extract a `createReducer` helper up front so event handling stays clean — skipping this refactor makes it painful to add later.

For side effects and services, use the dependency-injection extension: pass services into components at creation time via a context or service-locator pattern rather than hardcoding them. This keeps internal-tool components testable and decoupled from data sources.

What are the production risks to watch?

Be honest about the tradeoffs. A hand-built framework lacks the battle-tested edge-case coverage, tooling, and community support of React or Vue. Watch these pitfalls specifically:

- Passing the real DOM element to `patch` on every render — only works once; subsequent patches must use the stored `previous` VDOM.

- Forgetting the eventlisteners module, which makes handlers silently no-op.

- Not storing the last rendered VDOM, causing full re-renders or errors.

Scope the framework to what your internal tools actually need. If requirements grow toward complex routing, SSR, or a large component library, reassess whether a mainstream framework is now the better investment.

Next step: Prototype one real internal widget — say a live status card with a refresh action — using `createComponent`. Measure the bundle size and confirm patches are scoped. If the ergonomics fit your team, standardize the layered workflow as your internal component pattern.

// FREQUENTLY ASKED QUESTIONS

Is a hand-built framework production-safe for internal tools?

For scoped internal tools it can be, provided you respect the documented pitfalls: always patch against the stored previous VDOM, include Snabbdom's eventlisteners module, and never mutate state in place. It lacks the edge-case hardening and ecosystem of React or Vue, so keep the scope focused. Reassess if requirements grow toward routing, SSR, or large component libraries.

How small is the resulting bundle compared to React?

Significantly smaller — your only runtime dependency is Snabbdom, plus your own thin layers for templating, events, and reactivity. There's no JSX transpilation runtime or large framework core. For internal dashboards and embedded widgets where bundle size and auditability matter, this is a major advantage over React or Vue.

How do I inject data services into components?

Use the dependency-injection extension: pass services into components at creation time via a context or service-locator pattern instead of hardcoding them. Extend createComponent to accept a services or context argument and expose it to the template and methods. This keeps internal-tool components decoupled from their data sources and easier to test.