npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jdlanglois/emveecee

v0.3.0

Published

A tiny closure-first controller and router library for single-page applications.

Readme

emveecee

A tiny, closure-first TypeScript library for single-page applications. It owns routing and controller lifecycles, while your application owns its state, models, services, and rendering technology.

Install

npm install @jdlanglois/emveecee

Usage

import { createApp } from "@jdlanglois/emveecee";
import type { Controller } from "@jdlanglois/emveecee";

type AppDeps = {
  someService: SomeService;
};

const dashboardCtrl: Controller<AppDeps> = app => {
  async function load() {
    const message = await app.deps.someService.getMessage();
    app.target.innerHTML = `<h1>${message}</h1>`;
  }

  function unload() {
    app.target.replaceChildren();
  }

  return { load, unload };
};

const app = createApp<AppDeps>(({ router }) => {
  router.route("/", dashboardCtrl);

  return {
    someService: new SomeService(),
  };
});

app.start(document.body);

Each route activation creates a new controller closure. Put page-local state in that closure and application-wide state or services in app.deps.

Controllers are ordinary functions. JavaScript needs no helper:

const dashboardCtrl = app => ({
  load() {
    app.target.innerHTML = "<h1>Dashboard</h1>";
  },
});

TypeScript users who prefer contextual typing can optionally use ctrl():

import { ctrl } from "@jdlanglois/emveecee";

const dashboardCtrl = ctrl<AppDeps>(app => ({
  load() {
    app.deps.someService.load();
  },
}));

Routes

const productCtrl: Controller<AppDeps> = app => ({
  async load(route) {
    const product = await app.deps.someService.getProduct(
      route.params.id,
      { signal: route.signal },
    );

    if (!route.signal.aborted) {
      app.target.textContent = product.name;
    }
  },
}));

router.route("/products/:id", productCtrl);
router.route("*", notFoundCtrl);

The route passed to load() contains:

  • path: the current pathname
  • params: decoded named path parameters
  • query: the native URLSearchParams
  • signal: aborted when the controller is left

Navigate programmatically with app.navigate("/products/42"). Links marked with data-route are handled through the History API:

<a href="/products/42" data-route>View product</a>

Lifecycle

A controller must return load and may return unload:

const timerCtrl: Controller<AppDeps> = app => {
  let timer: ReturnType<typeof setInterval>;

  return {
    load() {
      timer = setInterval(render, 1000);
    },
    unload() {
      clearInterval(timer);
    },
  };
};

app.stop() removes navigation listeners, aborts the active route, and awaits its unload() function.

Views and redrawing

Views receive values and callbacks from their controller. The controller owns the model, handles application behavior, and decides when a redraw is needed. The view renders the values it receives and connects UI events to the supplied callbacks.

The resulting one-way loop is:

model -> controller render -> view -> callback -> model update -> render

A useful view contract is a target-bound renderer:

type View<Props> = {
  render(props: Props): void;
  dispose?(): void;
};

type CounterProps = {
  count: number;
  onIncrement(): void;
  onReset(): void;
};

The controller keeps its model private and passes the view only the values and actions it needs:

const counterCtrl: Controller<AppDeps> = app => {
  const model = {
    count: 0,
  };

  const view = createCounterView(app.target);

  function render() {
    view.render({
      count: model.count,
      onIncrement,
      onReset,
    });
  }

  function onIncrement() {
    model.count++;
    render();
  }

  function onReset() {
    model.count = 0;
    render();
  }

  return {
    load: render,
    unload: () => view.dispose?.(),
  };
};

The library does not automatically redraw when a model changes. This keeps models as ordinary objects and allows the controller to represent intermediate states explicitly:

async function onSave() {
  model.saving = true;
  render();

  try {
    await app.deps.products.save(model.product);
  } finally {
    model.saving = false;
    render();
  }
}

Direct DOM view

Replacing a view's subtree on each render also discards the old elements and their event listeners:

function createCounterView(target: HTMLElement): View<CounterProps> {
  return {
    render(props) {
      const root = document.createElement("section");
      const count = document.createElement("p");
      const increment = document.createElement("button");
      const reset = document.createElement("button");

      count.textContent = `Count: ${props.count}`;
      increment.textContent = "Increment";
      reset.textContent = "Reset";

      increment.addEventListener("click", props.onIncrement);
      reset.addEventListener("click", props.onReset);
      root.append(count, increment, reset);
      target.replaceChildren(root);
    },

    dispose() {
      target.replaceChildren();
    },
  };
}

Preact view

Preact keeps its render root associated with the target, so repeated calls to render() update the existing component tree:

import { h, render as renderPreact } from "preact";

function createCounterView(target: HTMLElement): View<CounterProps> {
  return {
    render(props) {
      renderPreact(
        h("section", null,
          h("p", null, `Count: ${props.count}`),
          h("button", { onClick: props.onIncrement }, "Increment"),
          h("button", { onClick: props.onReset }, "Reset"),
        ),
        target,
      );
    },

    dispose() {
      renderPreact(null, target);
    },
  };
}

The same controller can use a JSX-based Preact view; the controller only cares that the view implements render() and optionally dispose().

lit-html view

lit-html also preserves its rendering state between calls for the same target:

import { html, nothing, render as renderHtml } from "lit-html";

function createCounterView(target: HTMLElement): View<CounterProps> {
  return {
    render(props) {
      renderHtml(html`
        <section>
          <p>Count: ${props.count}</p>
          <button @click=${props.onIncrement}>Increment</button>
          <button @click=${props.onReset}>Reset</button>
        </section>
      `, target);
    },

    dispose() {
      renderHtml(nothing, target);
    },
  };
}

Responsibility boundary

Controllers should:

  • Own or load models and application state.
  • Call services and handle route parameters.
  • Define actions passed to the view.
  • Handle loading, success, and error states.
  • Decide when to render and dispose the active view.

Views should:

  • Turn values into UI.
  • Connect UI events to controller callbacks.
  • Handle presentation-specific behavior such as focus or animation.
  • Release resources owned by the rendering technology when disposed.

Views should call props.onIncrement() rather than mutating a controller model, calling application services, or navigating directly. Intent-oriented callback names such as onIncrement and onSave also keep views independent of whether an action came from a click, keyboard shortcut, or another UI event.

Hiccup-style view

The optional @jdlanglois/emveecee/view entry renders array-based views and diffs later renders against the current DOM:

import { render } from "@jdlanglois/emveecee/view";

render([
  "section#counter.panel",
  { "aria-live": "polite" },
  ["p", `Count: ${count}`],
  items.map(item => ["span.item", { key: item.id }, item.label]),
  null,
  ["button", { onClick: increment }, "Increment"],
], target);

The optional attributes object follows the selector. Nested child arrays are flattened, and null, undefined, and false children are omitted. Reusing the same target preserves compatible elements while updating attributes, event listeners, text, and children.

Attributes are optional. For efficient list updates, put a stable scalar key on each child. Alternatively, put a key function on the parent attributes:

render(["ul", { key: attrs => Number(attrs.id) },
  ...items.map(item => ["li", { id: item.id }, item.label]),
], target);

Keyed reconciliation moves existing DOM nodes when items are reordered and only creates or removes nodes for inserted or deleted keys. Keys must be unique among siblings and are not emitted as HTML attributes.

Size

The ESM view entry is 3,692 B (3.61 KiB) minified and 1,628 B (1.59 KiB) minified+gzip. These figures measure the complete dist/view.js produced by the current build. Rebuild and refresh the measurement with:

npm run size:view

Philosophy

  • View-library agnostic: use the optional Hiccup renderer, React, templates, or direct DOM manipulation.
  • No model abstraction: ordinary JavaScript objects are enough.
  • No automatic rendering: controllers decide when views are rendered.
  • No classes or inheritance: applications and controllers are closures.
  • No runtime dependencies.

License

MIT