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

@tobi2409/mvvm-monster

v0.9.14

Published

Lightweight reactive MVVM framework for declarative HTML and synchronized model/view-model collections

Downloads

254

Readme

mvvm-monster

A lightweight reactive MVVM framework for declarative HTML, synchronized model/view-model collections, and targeted DOM updates.

Features

  • Declarative templates with custom tags: <get>, <each>, <if>, <template-use>
  • Reactive updates without full re-rendering
  • Dependency-based refresh chaining (dependencies map)
  • Efficient key-to-node tracking via internal node holders
  • Nested context support for scoped access inside loops
  • Array update support: push, pop, shift, unshift, splice
  • Mapped model/view-model arrays with local state and prepared item insertion
  • Optional model change journaling with ModelJournal

Installation

import TemplateEngine from '@tobi2409/mvvm-monster'
import ViewModelArray from '@tobi2409/mvvm-monster/viewmodel-array'
import ModelJournal from '@tobi2409/mvvm-monster/model-journal'
import DataLoader from '@tobi2409/mvvm-monster/data-loader'

Examples (start here)

Browse the live demo files in the GitHub repository.

Featured demos:

Interesting snippets from those demos:

MVVM: computed fields + dependency chaining

import TemplateEngine from '@tobi2409/mvvm-monster'
import ViewModelArray from '@tobi2409/mvvm-monster/viewmodel-array'

const viewModel = TemplateEngine.reactive({
  get fullName() {
    return `${this.firstName} ${this.lastName}`
  },
  get showWage() {
    return this.wage > 600
  },
  get fullInfo() {
    return `${this.fullName} earns $${this.wage}`
  },
  getBeautifiedData() {
    return ViewModelArray.get(
      model.rawPersonData,
      (p) => ({
        id: p.id,
        name: p.name,
        age: new Date().getFullYear() - p.birthyear,
        showEdit: false
      }),
      (item) => ({
        id: () => item.id,
        name: () => item.name,
        birthyear: () => new Date().getFullYear() - item.age
      }),
      { age: 'birthyear' }
    )
  }
}, document.getElementById('app-template-use'), {
  firstName: ['fullName'],
  lastName: ['fullName'],
  wage: ['showWage', 'fullInfo'],
  fullName: ['fullInfo']
})

Notes:

  • Keeps stable mapped object identity per source item (internal cache).
  • Supports push, pop, shift, unshift, splice via source synchronization.
  • Add/insert/remove operations on mapped arrays are propagated back to the source model when reverseTransform is provided.

Recursive template: self-referencing <template-use>

<template id="folder-template">
  <each of="*list" as="item#">
    <li>
      📁 <get>item#.name</get>
      <ul>
        <template-use template-id="folder-template" data-list="item#.children"></template-use>
      </ul>
    </li>
  </each>
</template>
toggleEdit: (e, dataElement) => {
  dataElement.editing = !dataElement.editing
},
delete: (e, dataElement, _, contextStack) => {
  const parent = contextStack.get(`item-level-${contextStack.size - 3}`)
  if (parent?.data?.children) {
    const index = parent.data.children.findIndex((c) => c === dataElement)
    if (index !== -1) parent.data.children.splice(index, 1)
  }
}

The examples use local file paths and are intended to be run directly from the cloned repository. To try them out, clone the repo and open the HTML files in a browser:

git clone https://github.com/tobi2409/mvvm-monster.git
cd mvvm-monster

Quick Start

1) Define a <template>

<template id="user-template">
  <div class="user">
    <h2><get>name</get></h2>
    <p>Email: <get>email</get></p>

    <h3>Posts</h3>
    <each of="posts" as="post">
      <div class="post">
        <strong><get>post.title</get></strong>
        <p><get>post.content</get></p>
      </div>
    </each>
  </div>
</template>

<div id="mount-point"></div>
<template-use template-id="user-template" mount-id="mount-point"></template-use>

2) Initialize reactivity

const templateUse = document.querySelector('template-use')

const data = TemplateEngine.reactive(
  {
    name: 'Alice',
    email: '[email protected]',
    posts: [
      { title: 'First Post', content: 'Hello World!' },
      { title: 'Second Post', content: 'Learning TemplateEngine' }
    ]
  },
  templateUse
)

3) Update data

data.name = 'Alice Smith'
data.posts.push({ title: 'Third Post', content: 'Advanced features!' })
data.posts.splice(1, 0, { title: 'Inserted Post', content: 'In the middle!' })

Template Syntax

<get>key</get>

Renders a value from data/context.

<get>user.name</get>

<each of="array" as="item">...</each>

Loops over an array.

<each of="users" as="user">
  <div><get>user.name</get></div>
</each>

Note: Array items must be objects, not primitive values (strings, numbers, booleans). The engine uses a WeakMap internally to track item identity, which requires object references.

❌ Primitives are not supported:

data.tags = ['news', 'tech', 'sports']

✅ Wrap primitives in objects instead:

data.tags = [
  { value: 'news' },
  { value: 'tech' },
  { value: 'sports' }
]
<each of="tags" as="tag">
  <span><get>tag.value</get></span>
</each>

<if test="expr">...</if>

Conditionally renders content.

<if test="isVisible">
  <span>Visible content</span>
</if>

<template-use ...></template-use>

Mounts a <template> by ID.

<template-use template-id="user-template" mount-id="mount-point"></template-use>

API

TemplateEngine.reactive(data, templateUseNode, dependencies?)

Creates a reactive view-model around data using Object.defineProperties(...) and binds updates to DOM nodes generated from the referenced <template>.

  • data: source model object
  • templateUseNode: <template-use> element
  • dependencies (optional): dependency map for related refresh triggers

Returns: reactive view-model object

Known limitation: object replacement notifications

Replacing a nested object does not automatically notify all child keys. These patterns are currently not sufficient on their own:

d.person.address = { city: 'Köln' }
d.selectedPerson = { name: 'Mia' }

Use an explicit child assignment afterwards to trigger the child-key refresh:

d.person.address = { city: 'Köln' }
d.person.address.city = 'Köln'

d.selectedPerson = { name: 'Mia' }
d.selectedPerson.name = 'Mia'

Dependencies

Use the optional dependencies map when one property affects other derived properties.

const raw = { firstName: 'Alice', lastName: 'Smith' }

const data = TemplateEngine.reactive(
  {
    get firstName() { return raw.firstName },
    set firstName(v) { raw.firstName = v },
    get fullName() { return `${raw.firstName} ${raw.lastName}` }
  },
  document.querySelector('template-use'),
  {
    firstName: ['fullName']
  }
)

data.firstName = 'Bob' // triggers refresh for firstName and fullName

Why this matters:

  • Keeps derived values in sync without manual DOM handling.
  • Makes reactive chains explicit and maintainable.
  • Works well for computed/display-only fields.

ViewModelArray

ViewModelArray.get(modelArray, transform, reverseTransform?, propertyMapping?, state?) maps model items to view-model items and keeps the mapped array associated with its source array. It returns a stable { data, state } container.

import ViewModelArray from '@tobi2409/mvvm-monster/viewmodel-array'

const viewModelPersons = ViewModelArray.get(
  model.persons,
  (person) => ({
    id: person.id,
    name: person.name,
    age: new Date().getFullYear() - person.birthyear
  }),
  (person) => ({
    id: () => person.id,
    name: () => person.name,
    birthyear: () => new Date().getFullYear() - person.age
  }),
  { age: 'birthyear' }
)

Arguments:

  • modelArray: source array containing model objects.
  • transform: required function that creates a view-model item.
  • reverseTransform: optional function used to synchronize view-model changes back to the model. Its returned properties should be functions, so only the properties affected by a partial update need to be evaluated. It defaults to the identity transformation.
  • propertyMapping: optional object mapping view-model property names to model property names, for example { age: 'birthyear' }.
  • state: optional object for UI state and actions associated with this mapped array.

The returned data array is used by <each>. The returned state object can hold controls that do not belong to model items:

const persons = ViewModelArray.get(
  model.persons,
  transformPerson,
  reverseTransformPerson,
  { age: 'birthyear' },
  {
    newPerson: { name: '' },
    expanded: true
  }
)

persons.data.push({ id: 1, name: 'Alice', age: 30 })
persons.state.newPerson.name = 'Bob'

Because ViewModelArray.get caches by modelArray, repeated calls return the same container. The transform, reverseTransform, propertyMapping, and state from the first call remain in use.

ViewModelArray.get caches the mapped array for a source array and preserves mapped item identity. Array operations such as push, pop, shift, unshift, and splice can therefore be rendered reactively and synchronized back to the model when reverseTransform is provided. For partial property updates, the functions returned by reverseTransform prevent unrelated model properties from being recalculated.

Preparing incomplete view-model items

ViewModelArray.prepareItem(viewModelArrayData, preparedViewModelItem) converts an incomplete view-model item to a model item and then applies the normal forward transform. It returns both objects without inserting them:

const { modelItem, viewModelItem } = ViewModelArray.prepareItem(persons.data, {
  name: 'Alice',
  age: 30
})

For insertion into a reactive mapped array, pass the preparation flag as the last array argument. The options object itself is not inserted:

persons.data.push(
  { name: 'Alice', age: 30 },
  { extraArrayParams: { preparedViewModelItem: true } }
)

The same options format works with unshift and splice. The engine prepares every inserted item before rendering and synchronizes the corresponding model items. Use this only for items that still need the mapped array's reverse and forward transforms; ordinary complete view-model items can be inserted without the flag.

DataLoader

DataLoader.load applies fetched data to one directly rendered model array.

import DataLoader from '@tobi2409/mvvm-monster/data-loader'

DataLoader.load(
  result.items,
  parent,
  rootData,
  append
)

Use ExpandHandler.create(loadData) for lazy child loading and expanded-state toggling.

import ExpandHandler from '@tobi2409/mvvm-monster/expand-handler'

const expand = ExpandHandler.create((parent) => loadChildren(parent))

MVVMDataLoader provides the corresponding API when model and view model are separate. It updates the model array and transforms the loaded items into the view-model array.

import MVVMDataLoader from '@tobi2409/mvvm-monster/mvvm-data-loader'

MVVMDataLoader.load(
  result.items,
  viewModelParent,
  modelParent,
  rootViewModelArray,
  rootModelArray,
  append
)

ModelJournal

ModelJournal.reactive(data, identifierProperty?) instruments a model and logs property and array changes to console.log. Array paths use id by default; pass another identifier property when required.

import ModelJournal from '@tobi2409/mvvm-monster/model-journal'

const model = ModelJournal.reactive({
  persons: [{ personId: 'p1', name: 'Alice' }]
}, 'personId')

model.persons[0].name = 'Alicia'
// { fullKey: 'persons.p1.name', change: { operation: 'set', value: 'Alicia' } }

Use ModelJournal.withoutJournaling(callback) for changes that should not be logged. ModelJournal.isJournalingDisabled() reports whether such a scope is currently active.

await ModelJournal.withoutJournaling(async () => {
  model.persons.push({ personId: 'p2', name: 'Bob' })
})

Internal architecture

Internal dependencies follow one direction:

  • identity, transforms, and dom: focused import-free primitives.
  • resolution: key and alias resolution built on stable item identity.
  • reactivity and model: observation and model/view-model synchronization.
  • rendering: initial rendering followed by refresh, delegation, and notification.
  • dataloaders and collections: public data-loading and collection utilities.
  • template-engine.js: the top-level composition root.

Production imports point only to explicitly lower abstractions. Tests mirror the source areas and may import their subject plus lower-level collaborators, never a peer or a higher-level module.

Refresh orchestration belongs to Notifier; the pure DependencyResolver only calculates matching dependency keys. Lower layers therefore never import the rendering layer.

Technical background: NodeHolders and UUID identity

  • NodeHolders: The engine tracks which DOM nodes depend on a particular "full key" using a segmented Map managed by the node-holders utility (src/dom/node-holders.js). Full keys (for example users.3.name or item#.children.2.title) are split into segments and stored in nested Maps; the leaf entries contain arrays of node-holders that reference that full key. When a property changes the engine builds the full key and looks up any matching holders to refresh — this enables targeted updates without scanning the entire DOM.

  • UUID / item identity: For arrays the engine keeps stable per-item identities using a WeakMap-backed id cache (see src/identity/uuid-item-map.js). When rendering <each> the engine assigns each object a stable id so that moving, inserting, or deleting items preserves existing DOM nodes for unchanged items. That reduces DOM churn and keeps per-item state (inputs, event handlers) stable across array mutations.

  • Benefits: targeted refreshes for changed keys, minimal DOM re-creation, efficient nested/context lookups, and stable per-item state during array operations.

  • Caveats: identity tracking requires array items to be objects (not primitives). Also, replacing a nested object does not automatically notify child keys — use explicit child-key assignments when needed (see "Known limitation: object replacement notifications").

References:

Development

Run tests:

npm test

Status

Active development — API may evolve.