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

@cotera/watchtower-models

v0.1.2

Published

The WatchTower viewmodel layer: model scopes, lookup by type, and targeting

Readme

@cotera/watchtower-models

The WatchTower viewmodel layer.

A model is an object with a stable id, registered into a scope for as long as the subtree that owns it is mounted, and found again by its type rather than by threading a reference down the tree.

Models commonly hold their state in watchables from @cotera/watchtower, but this package does not depend on them — a model is whatever you register. Pair it with @cotera/watchtower-actions to give actions something to read.

Install

bun add @cotera/watchtower-models
bun add react   # peer

Models

A model is a viewmodel: an object with an id, usually holding watchables, put into scope for the subtree that owns it. A ModelScopeProvider at the app root owns the registry they go into — see Scopes.

import {
  ModelScopeFactory,
  useInScopeModel,
  type TargetableModel,
} from '@cotera/watchtower-models';
import { Watchable, useWatchableValue } from '@cotera/watchtower';

class RunModel implements TargetableModel {
  readonly id: string;
  readonly title = Watchable.fromValue('Run Details');

  constructor(runKey: string) {
    this.id = `run-${runKey}`;
  }

  isTargeted = false;
  target = () => {
    this.isTargeted = true;
  };
  markAsNotTargeted = () => {
    this.isTargeted = false;
  };
}

function RunPage({ runKey }: { runKey: string }) {
  // builds the model inside the effect and registers it for as long as this
  // page is mounted; `deps` decides when it is rebuilt, the same as `useMemo`
  return (
    <ModelScopeFactory createModels={() => [new RunModel(runKey)]} deps={[runKey]}>
      <RunTitle />
    </ModelScopeFactory>
  );
}

function RunTitle() {
  const model = useInScopeModel(RunModel);
  return <h1>{useWatchableValue(model.title)}</h1>;
}

Scopes nest, and lookups walk up to the parent — a component asks for a model type and gets the nearest one. Registration is refcounted, so two sibling subtrees registering the same model do not tear it down for each other when the first unmounts. dispose() runs when the last registrant leaves.

ModelScopeFactory takes a factory rather than instances on purpose. Running it inside the effect, keyed on deps, is what keeps registration stable: a models={[model]} prop would be a fresh array on every render and re-register on every render, and instances built outside the effect get re-registered after a remount even though unmounting already disposed them — the "zombie model" React Strict Mode surfaces.

Targeting answers "which one did the user mean" when several models of a type coexist. createProvidedModelContext hands descendants a specific instance and marks it targeted, while ModelTargetScope gates that on whether the subtree is actually the visible one — so a hidden tab releases targeting instead of holding it forever by virtue of having mounted last.

const RunContext = createProvidedModelContext(RunModel, 'No run in scope');

<ModelTargetScope active={isVisibleTab}>
  <RunContext.Provider modelId={`run-${runKey}`}>
    <RunEditor />
  </RunContext.Provider>
</ModelTargetScope>;

Scopes

ModelScopeProvider owns a ModelScopeManager — the registry itself. Every lookup, registration, and targeting call goes through it, and the hooks read the nearest one.

<ModelScopeProvider>
  <App />
</ModelScopeProvider>

ActionsRegistryProvider from @cotera/watchtower-actions renders one of these internally with the scope its actions read from, so the two layers share a single registry — you do not nest them yourself.

Providers nest, and a nested one parents itself to the enclosing scope: lookups walk up, and a child hears about changes in its parents (it can see their models) but not the reverse. A scope created by a provider is disposed when that provider unmounts.

For work outside React — a test, or a headless caller — build one directly:

const scope = ModelScopeManager.create();
scope.addModels([new DocModel('a')]);
scope.getModelOfType(DocModel);
scope.subscribe((models) => console.log(models.length));

Development

bun install       # from the workspace root
bun run test:run
bun run typecheck