Models

Use app.model() to declare shared reactive application state as an injectable service.

app.model(name, initial) declares a shared reactive model service. The injected value is the proxied model object itself. Use models for shared, declarative application state that should outlive one DOM scope tree.

Prefer the factory form:

const app = angular.createModule('demo', []);

app.model('user', () => ({
  name: 'John',
  authenticated: false,
}));

Then inject the model by name. Assigning the injected model to a controller property or $scope property makes the model readable from templates:

class HeaderController {
  static $inject = ['user'];

  constructor(public user: { name: string; authenticated: boolean }) {}
}
<section ng-controller="HeaderController as header">
  <strong>{{ header.user.name }}</strong>
  <span>{{ header.user.authenticated ? "signed in" : "guest" }}</span>
</section>

When the model changes, DOM interpolation, ng-bind, and directive expressions that read that model update automatically. Mutating the model proxy is sufficient to schedule every affected observer.

Model data belongs to the app context, not to $rootScope. Every root scope managed by the same app context resolves the same model instance. Destroying a root scope removes that root’s observations, but the model data survives while the app context remains alive.

Access From External Code

After the app has started with angular.bootstrap() or angular.injector(), use angular.getModel(name) to access a model from external code:

const user = angular.getModel<{ name: string; authenticated: boolean }>('user');

// An external callback can update the reactive model directly.
setTimeout(() => {
  user.name = 'Jane';
}, 1000);

The first lookup initializes the model if needed. Subsequent lookups and dependency injection return the same instance. Mutations schedule updates for every observing view without an explicit digest or $apply() call.

The getter only accepts models declared by loaded modules. Unknown names, ordinary service names, and access before app initialization or after app destruction throw errors. Use the Angular runtime instance that owns the app when working with multiple applications.

Models Versus Local Scope

Use models for shared domain state such as user, cart, session, and settings.

Use $scope for view-local state that only exists to support one template, such as an open menu flag, a focused row id, or transient form display state.

Use $eventBus for cross-boundary messages, $machine for finite-state transitions, and $workflow for command orchestration.

Template Reactivity

App models can be bound directly:

<p>{{ user.name }}</p>
<p ng-bind="user.profile.name"></p>
<p>{{ cart.items.length }}</p>

Controller-as bindings are first-class:

<section ng-controller="ProfileCtrl as profile">
  <input ng-model="profile.user.name" />
  <button ng-click="profile.signIn()">Sign in</button>
</section>

<section ng-controller="HeaderCtrl as header">
  <strong>{{ header.user.name }}</strong>
  <span>{{ header.user.authenticated ? "signed in" : "guest" }}</span>
</section>

Nested object reads, parent replacement, array length reads, array mutation, and property deletion follow the same scope proxy semantics as local scope state.

Model Shape

Root models must start as plain objects. Primitives, null, arrays, and class instances are rejected as root model values. Nested values follow AngularTS scope proxy behavior. Duplicate model names in one module are rejected.

Model factories should initialize state only. Put sockets, workers, timers, DOM listeners, and other side effects in services, workflows, machines, or policy-backed browser abstractions.

Model Methods

Injected models are proxy-backed objects. They expose their own data properties, the scope-proxy methods used by AngularTS reactivity, and model lifecycle methods for whole-model coordination.

Those methods exist because models are implemented with the same proxy engine as scopes. Application code should still treat a model as app-owned state, not as a DOM scope.

Model-specific methods use ordinary method names:

  • snapshot() creates a plain snapshot of model state.
  • restore(snapshot, options?) restores model state and notifies observers.
  • sync(target) synchronizes model updates with a direct target or injectable target factory.

sync() is intended for integrations such as Unity, WebSocket sessions, workers, CRDT documents, and custom stores. The external runtime may update the model imperatively, but AngularTS keeps the rest of the app declarative:

runtime event
  -> model assignment
  -> model.sync(...)
  -> websocket/store/worker/crdt target

Target factories should use normal AngularTS injectable form. Bare string service-name shortcuts are intentionally avoided for consistency:

playerModel.sync(['playerSocketSync', (playerSocketSync) => playerSocketSync]);
settingsModel.sync([
  '$cookie',
  ($cookie) => ({
    restore: () => $cookie.getObject('settings'),
    write: (snapshot) => $cookie.putObject('settings', snapshot),
  }),
]);

A managed worker already exposes a model sync target, so no callback bridge is required:

app.worker('physicsWorker', '/workers/physics.js', { restart: true });

app.run([
  'playerModel',
  'physicsWorker',
  (playerModel, physicsWorker) =>
    playerModel.sync(physicsWorker.model('player')),
]);

The worker exchanges standard ng.WorkerModelMessage envelopes. Incoming snapshots update every DOM scope that consumes the model, while model mutations are sent back with change metadata and loop-prevention origin tracking.

sync() batches synchronous model mutations, sends a plain snapshot plus modest change metadata, and uses origin metadata to prevent bidirectional sync loops. Failures should report through AngularTS exception handling by default.

Durable storage should start as an explicit sync() target. A settings model can restore from $cookie and write future snapshots back to the same storage boundary without adding persistence behavior to the model declaration itself.

A sync target can implement any combination of restore, write, receive, and dispose:

playerModel.sync([
  'playerSocketSync',
  (socketSync) => ({
    restore: () => socketSync.latest(),
    write: (snapshot, change) => socketSync.send({ snapshot, change }),
    receive: (apply) => socketSync.onMessage((snapshot) => apply(snapshot)),
    dispose: () => socketSync.close(),
  }),
]);

sync() checks the target at runtime. A target must implement at least one recognized operation, and every supplied operation must be a function. restore() results and snapshots passed to receive() must be plain objects; invalid external values are reported through the configured sync failure policy without partially updating the model. receive() must return either a disposer function or undefined.

Runtime validation establishes the model container contract, but it cannot recover erased TypeScript property types. Sync targets remain responsible for decoding and validating domain fields received from untrusted external data.

restore(snapshot, { mode: "merge" }) keeps existing model keys and writes the incoming keys. The default mode replaces the model root’s keys with the snapshot’s keys.

Executable Sample

<!doctype html>
<html>
  <head>
    <script type="module">
      import "../../utils/source-bootstrap.js";
      import "./user-model.js";
    </script>
  </head>
  <body ng-app="modelDemo">
    <section ng-controller="ProfileCtrl as profile">
      <label>
        Name
        <input
          ng-model="profile.user.name"
          aria-label="Name"
          data-testid="profile-name"
        />
      </label>
      <button ng-click="profile.signIn()">Sign in</button>
      <button ng-click="profile.reset()">Reset</button>
    </section>

    <section ng-controller="HeaderCtrl as header">
      <strong data-testid="header-name">{{ header.user.name }}</strong>
      <span data-testid="header-status">
        {{ header.user.authenticated ? 'signed in' : 'guest' }}
      </span>
      <span data-testid="cart-count">{{ header.cart.items.length }}</span>
    </section>
  </body>
</html>
{{ header.user.name }} {{ header.user.authenticated ? 'signed in' : 'guest' }} {{ header.cart.items.length }}

HTML:

<!doctype html>
<html>
  <head>
    <script type="module">
      import "../../utils/source-bootstrap.js";
      import "./user-model.js";
    </script>
  </head>
  <body ng-app="modelDemo">
    <section ng-controller="ProfileCtrl as profile">
      <label>
        Name
        <input
          ng-model="profile.user.name"
          aria-label="Name"
          data-testid="profile-name"
        />
      </label>
      <button ng-click="profile.signIn()">Sign in</button>
      <button ng-click="profile.reset()">Reset</button>
    </section>

    <section ng-controller="HeaderCtrl as header">
      <strong data-testid="header-name">{{ header.user.name }}</strong>
      <span data-testid="header-status">
        {{ header.user.authenticated ? 'signed in' : 'guest' }}
      </span>
      <span data-testid="cart-count">{{ header.cart.items.length }}</span>
    </section>
  </body>
</html>

JavaScript:

window.angular
  .createModule('modelDemo', [])
  .model('user', () => ({
    name: 'John',
    authenticated: false,
  }))
  .model('cart', () => ({
    items: ['starter'],
  }))
  .controller(
    'ProfileCtrl',
    class {
      static $inject = ['user', 'cart'];

      constructor(user, cart) {
        this.user = user;
        this.cart = cart;
      }

      signIn() {
        this.user.authenticated = true;
        this.cart.items.push('welcome');
      }

      reset() {
        this.user.name = 'John';
        this.user.authenticated = false;
        this.cart.items = ['starter'];
      }
    },
  )
  .controller(
    'HeaderCtrl',
    class {
      static $inject = ['user', 'cart'];

      constructor(user, cart) {
        this.user = user;
        this.cart = cart;
      }
    },
  );

Sync Sample

<!doctype html>
<html>
  <head>
    <script type="module">
      import "../../utils/source-bootstrap.js";
      import "./settings-sync.js";
    </script>
  </head>
  <body ng-app="settingsSyncDemo" ng-controller="SettingsCtrl as settings">
    <label>
      Theme
      <select
        ng-model="settings.model.theme"
        aria-label="Theme"
        data-testid="theme"
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
    </label>

    <label>
      Compact
      <input
        type="checkbox"
        ng-model="settings.model.compact"
        aria-label="Compact"
        data-testid="compact"
      />
    </label>

    <button ng-click="settings.reset()">Reset</button>

    <p data-testid="summary">
      {{ settings.model.theme }} /
      {{ settings.model.compact ? 'compact' : 'comfortable' }}
    </p>
    <p data-testid="sync-count">{{ settings.sync.writes }}</p>
  </body>
</html>

{{ settings.model.theme }} / {{ settings.model.compact ? 'compact' : 'comfortable' }}

{{ settings.sync.writes }}

HTML:

<!doctype html>
<html>
  <head>
    <script type="module">
      import "../../utils/source-bootstrap.js";
      import "./settings-sync.js";
    </script>
  </head>
  <body ng-app="settingsSyncDemo" ng-controller="SettingsCtrl as settings">
    <label>
      Theme
      <select
        ng-model="settings.model.theme"
        aria-label="Theme"
        data-testid="theme"
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
    </label>

    <label>
      Compact
      <input
        type="checkbox"
        ng-model="settings.model.compact"
        aria-label="Compact"
        data-testid="compact"
      />
    </label>

    <button ng-click="settings.reset()">Reset</button>

    <p data-testid="summary">
      {{ settings.model.theme }} /
      {{ settings.model.compact ? 'compact' : 'comfortable' }}
    </p>
    <p data-testid="sync-count">{{ settings.sync.writes }}</p>
  </body>
</html>

JavaScript:

window.angular
  .createModule('settingsSyncDemo', [])
  .model('settingsModel', () => ({
    theme: 'light',
    compact: false,
  }))
  .model('settingsSyncModel', () => ({
    writes: 0,
  }))
  .controller(
    'SettingsCtrl',
    class {
      static $inject = ['settingsModel', 'settingsSyncModel', '$cookie', '$scope'];

      constructor(settingsModel, syncModel, $cookie, $scope) {
        this.model = settingsModel;
        this.sync = syncModel;
        this.$cookie = $cookie;
        this.stopSync = settingsModel.sync([
          '$cookie',
          ($cookieService) => ({
            restore: () => $cookieService.getObject('angular_ts_settings') ?? null,
            write: (snapshot) => {
              syncModel.writes += 1;
              $cookieService.putObject('angular_ts_settings', snapshot, {
                path: '/',
              });
            },
          }),
        ]);

        $scope.on('$destroy', this.stopSync);
      }

      reset() {
        this.$cookie.remove('angular_ts_settings', { path: '/' });
        this.model.restore({
          theme: 'light',
          compact: false,
        });
      }
    },
  );