vanilla-way-mvvm
v0.1.0
Published
A tiny MVVM component helper for vanilla-way: model + vm + view wiring with a seed/bind prop rule.
Maintainers
Readme
vanilla-way-mvvm
An MVVM composition layer over vanilla-way.
The reactivity core stays a single instance: vanilla-way is a
peerDependency, never bundled. Zero runtime dependencies.
It exports one runtime helper:
component({ view, model?, vm? })which wires a 3-layer MVVM convention into a single typed call site. Type
inference flows model -> vm -> view. Only view is required — a view-only
component({ view }) is valid; model and vm are optional.
You never hand-write that call. A small zero-dep CLI — vanilla-way-mvvm gen —
generates each component's wiring from its folder. See
Codegen.
Folder convention
A component is authored as a folder of small, single-responsibility files:
Counter/
Counter.view.tsx // REQUIRED: dumb view, reads the vm, returns JSX
Counter.model.ts // OPTIONAL: schema + default values, e.g. { count: 0 }
Counter.vm.ts // OPTIONAL: derive signals / actions from the store
Counter.scss // OPTIONAL: styles (.scss or .css)
index.ts // AUTO-GENERATED glue: component({ view, model?, vm? })<Name>.view.tsx is the only required file — its presence is what marks the
folder as a component. Everything else is optional. You author the small files;
you do not hand-write index.ts — the codegen CLI
writes it for you.
Codegen: vanilla-way-mvvm gen
The package ships a tiny CLI (Node built-ins only — stays zero-dep) that scans a
components root, finds every component folder, and writes its index.ts glue.
# one-shot: generate index.ts for every component under the root
npx vanilla-way-mvvm gen
# regenerate on change, for real-time dev alongside a dev server
npx vanilla-way-mvvm gen --watch- Command —
genis the only subcommand. - Root — defaults to
src/components. Override with--root <path>/--root=<path>, or as a positional arg:vanilla-way-mvvm gen packages/ui. - What it does — for each folder containing a
<Name>.view.tsx, it emits<Name>/index.tsthat imports whichever of<Name>.model.ts,<Name>.vm.tsand<Name>.scss/.cssexist and callscomponent({ view, model?, vm? }). - Write-only-if-changed — identical output is left untouched, so it never
churns git or triggers needless reloads.
--watchalso ignores its ownindex.tsoutput.
Wire it into package.json scripts and run the watcher next to your bundler —
one command for real-time dev:
{
"scripts": {
"gen": "vanilla-way-mvvm gen",
"dev": "concurrently \"vanilla-way-mvvm gen --watch\" \"vite\""
}
}The generated index.ts is committed and is marked
// AUTO-GENERATED by vanilla-way-mvvm. Do not edit. — treat it as build output
you keep in the tree, not a file you edit by hand.
The prop rule (seed vs bind)
For each model field, a component instance accepts either a plain value or a signal:
- A plain value seeds the field — a fresh signal is created, initialised to that value, and owned by the instance (cleaned up on dispose).
- A signal binds the field — the passed signal is the state. It is shared two-way and is never disposed by the component.
- Omitting a field seeds it with the model default.
<Counter count={0} /> // seed: independent local state
<Counter count={someSignal} />// bind: shared two-way with someSignalThe discriminator is structural (isReadable from vanilla-way): anything with
.get + .subscribe binds; everything else seeds.
Worked example
A full component folder — Counter/ — with model, vm, view and styles. You
author these three files (plus Counter.scss):
// Counter.model.ts — schema + defaults
const model = { count: 0 };
export default model;// Counter.vm.ts — derive signals / actions from the store
import type { StoreOf } from "vanilla-way-mvvm";
type Model = { count: number };
export default (s: StoreOf<Model>) => ({
...s,
doubled: s.count.derive((n) => n * 2),
inc: () => s.count.set(s.count.get() + 1),
reset: () => s.count.set(0),
});// Counter.view.tsx — dumb view over the vm
export default (vm) => (
<div class="counter">
<span>{vm.count}</span>
<span>x2 = {vm.doubled}</span>
<button onClick={vm.inc}>+</button>
<button onClick={vm.reset}>reset</button>
</div>
);Running vanilla-way-mvvm gen writes the glue for you — imports only the files
that exist:
// Counter/index.ts
// AUTO-GENERATED by vanilla-way-mvvm. Do not edit.
import { component } from 'vanilla-way-mvvm';
import view from './Counter.view';
import model from './Counter.model';
import vm from './Counter.vm';
import './Counter.scss';
export const Counter = component({ model, vm, view });A view-only component is just a folder with a single view file — Hello/:
// Hello.view.tsx
export default () => <div class="hello">Hello from a view-only component.</div>;which generates the minimal glue (no model, vm, or style import):
// Hello/index.ts
// AUTO-GENERATED by vanilla-way-mvvm. Do not edit.
import { component } from 'vanilla-way-mvvm';
import view from './Hello.view';
export const Hello = component({ view });