A $state Field for PolarisMode ReactiveResources Proposed
Summary
withDefaults from @warp-drive/core/reactive adds a new derived field, $state, to every PolarisMode resource schema it builds, and registerDerivations registers the @state derivation that backs it. $state is a stable, read-only, reactive object describing the resource's lifecycle in the cache, and which of its fields have local changes:
interface ReactiveResourceState {
readonly isNew: boolean;
readonly isEmpty: boolean;
readonly isDeleted: boolean;
readonly isDeletionCommitted: boolean;
readonly isDirty: boolean;
readonly changes: Readonly<Record<string, ResourceFieldChange | undefined>>;
}It is the PolarisMode counterpart to the state flags LegacyMode puts directly on a record (isNew, isEmpty, isDeleted, hasDirtyAttributes, changedAttributes(), ...), keeping the ones that describe the resource itself, cleaning up their names and return types, and dropping the ones that describe a request or an EmberObject lifecycle instead.
Motivation
A PolarisMode resource built with withDefaults today exposes its identity (id, $key, $type) and its data, but nothing about its lifecycle. To answer "is this record new?", "does it have unsaved changes?" or "which fields did the user change?" an app has to reach past the resource into the cache:
const key = recordIdentifierFor(user);
const isNew = store.cache.isNew(key);
const isDirty = store.cache.hasChangedAttrs(key) || store.cache.hasChangedRelationships(key);
const nameChange = store.cache.changedAttrs(key).name;None of those reads are reactive: the cache is not a signal-backed structure, so a template or component that reads them will not update when the value changes. Getting reactivity means subscribing to the NotificationManager by hand and managing that subscription's lifetime, which is exactly the machinery RecordState already implements for LegacyMode.
This gap shows up as soon as an app migrates from Model (or LegacyMode schemas) to PolarisMode. Every edit form needs "has unsaved changes" and usually "this field was changed" indicators, and every one of them is currently re-implementing it, usually without reactivity or without cleaning up its subscriptions.
The goal is for the canonical PolarisMode resource to answer those questions itself, reactively, without adding a second, parallel copy of LegacyMode's flag soup to it.
Detailed design
The field
withDefaults appends one field to the schema:
{ kind: 'derived', name: '$state', type: '@state' }registerDerivations registers the @state derivation alongside the existing @identity and @constructor derivations. useRecommendedStore already calls registerDerivations, so apps using it get $state with no further setup.
It is named with a $ prefix for the same reason $type and $key are: it is metadata about the resource rather than data from it, and the prefix keeps it from colliding with a real field named state (a common attribute name in its own right — an order's state, a US state).
$state is:
- Stable.
record.$state === record.$state. The derivation creates the state object once per record instance and memoizes it; its properties are what change. - Read-only. Setting
$stateor any of its properties is an error, the same as any derived field. - Non-enumerable. It is excluded from
Object.keys(record),{ ...record }, andJSON.stringify(record), the same asconstructor, so adding it towithDefaultsdoesn't change what apps that serialize or spread records get.$stateitself implementstoJSONfor debugging. - Only on resources.
withDefaultsonly builds resource schemas; embedded objects (schema-object,schema-arraymembers) have no lifecycle of their own and don't get$state. - Only about the cache. Every property is derived from the cache's state for the resource.
$statehas no knowledge of requests; see Request state.
Properties
| Property | Source | Invalidated by |
|---|---|---|
isNew | cache.isNew(key) | 'state' notifications |
isEmpty | !cache.isNew(key) && cache.isEmpty(key) | 'state', 'attributes' notifications, and record teardown |
isDeleted | cache.isDeleted(key) | 'state' notifications |
isDeletionCommitted | cache.isDeletionCommitted(key) | 'state' notifications |
isDirty | see below | 'state', 'attributes', 'relationships' notifications, both channels |
changes | cache.changedAttrs(key) / cache.changedRelationships(key), per field | see Per-field changes |
Every read is computed fresh from the cache; the notifications only invalidate reactive consumers so that they re-read. Store notifications are delivered in batches, so caching a value until its notification arrives would make a read immediately after an edit (editable.name = 'x'; editable.$state.isDirty) return the stale value. Computing on read avoids that, and every property is a cheap cache lookup.
isDirty is the LegacyMode isDirty computation, extended to relationships:
if (isDeletionCommitted || (isDeleted && isNew)) return false;
return isDeleted || isNew || cache.hasChangedAttrs(key) || cache.hasChangedRelationships(key);A new resource deleted before it was ever saved, and a resource whose deletion has been committed, have nothing left to persist, so they are not dirty.
isDirty and changes listen to both the 'local' and 'remote' notification channels. A local edit changes the local projection, but a remote update whose value matches a pending local edit resolves that edit while changing only the remote projection; listening to one channel would miss one of the two.
isEmpty
isEmpty reports exactly what the cache reports: it is !cache.isNew(key) && cache.isEmpty(key). A new resource is never empty, matching LegacyMode. $state does not redefine what "empty" means; for the JSON:API cache, it means the resource has no field data at all.
For a PolarisMode instance, that is most visible when a resource is removed from the store while still referenced. store.unloadRecord tears the record instance down before the cache releases the resource's data, so by the time the data is gone nothing is subscribed to hear about it. To cover that, tearing down a record invalidates isEmpty, so a template still rendering the unloaded record re-reads it and sees true.
Whether isEmpty should also cover a resource that was loaded without any field values, a case that is becoming more common now that partial fields are supported, is deliberately left for a later time; see Unresolved questions.
Per-field changes
changes is an object keyed by field name. A field with local changes has an entry; a field without them does not:
editable.name = 'Christopher';
user.$state.changes.name;
// => { kind: 'field', remoteState: 'Chris', localState: 'Christopher' }
user.$state.changes.age;
// => undefined
Object.keys(user.$state.changes);
// => ['name']An entry is a ResourceFieldChange:
type ResourceFieldChange = FieldChange | RelationshipDiff;
interface FieldChange {
kind: 'field';
remoteState: Value | undefined;
localState: Value;
}- A non-relationship field (
field,array,object,schema-object,schema-array) produces aFieldChange, built from the cache'schangedAttrsentry for that field. ItsremoteState/localStatenaming matchesRelationshipDiff, so the two read the same way. Values are in the form the cache stores them, before anyTransformationis applied. - A relationship (
belongsTo,hasMany,resource,collection) produces the cache's existingRelationshipDifffor that field:kind: 'resource'for to-one, withremoteState/localStatekeys, andkind: 'collection'for to-many, withadditions,removalsandreorderedas well. - Identity,
derived,aliasand@localfields hold no cache data of their own and never have an entry.
Entries are keyed by the field's name, not its sourceKey, and nested changes (inside an object or schema-object field) are reported on the top-level field that contains them.
Each entry is reactive on its own. Reading changes.name consumes a signal for name only, which is invalidated by an 'attributes' notification for name's cache key (or a path starting with it), so a component rendering the name input's "changed" indicator does not re-render when age changes. Reading the set of keys (Object.keys, in on an unchanged field, iterating) consumes a signal invalidated by a change to any field. A 'state' notification (commit, rollback) and a keyless notification invalidate every entry.
Both projections share one state
A PolarisMode resource has two projections: the immutable instance and its editable checkout. $state describes the resource in the cache, not a projection, so both report the same values: after editable.name = 'Christopher', user.$state.isDirty is true and user.$state.changes.name is populated on the immutable instance too. This is deliberate. The question "does this resource have unsaved changes" has one answer, and the immutable instance is usually what the rest of the UI (a list row, a nav badge) is rendering when it wants to show that answer. The immutable instance still does not show the edited values in its fields, only that changes exist.
Request state
$state deliberately has no request-derived properties: no isSaving, isError, error, errors or isValid. Those describe a request, not the resource, and PolarisMode already makes requests reactive: getRequestState(future) and the <Request> component report whether a save is pending, whether it failed and with what error. Keeping them out of $state:
- keeps
$statea pure projection of the cache, with no dependency on theRequestStateService, whose record tracking only registers the first entry ofrequest.recordsfor a mutation; - avoids a resource-level summary of requests that is ambiguous when several requests touch the same resource at once;
- leaves validation errors, which arrive as the rejection of a save request, with that request. The cache still stores them per resource and
cache.getErrors(key)still returns them; see Unresolved questions for surfacing them per field.
Lifetime
The state object subscribes to the NotificationManager for its resource the first time $state is read, and unsubscribes when the record is torn down: the same point at which the record's own notification subscription is released, including the editable checkout when its immutable instance is torn down. A record whose $state is never read pays nothing for it.
What is not carried over, and why
| LegacyMode | In $state | Why |
|---|---|---|
isNew | isNew | unchanged |
isEmpty | isEmpty | unchanged; also becomes true when a still-referenced record is unloaded |
isDeleted | isDeleted | unchanged |
currentState.isDeletionCommitted / isSaved for deletes | isDeletionCommitted | the only part of isSaved that isn't already !isDirty |
hasDirtyAttributes, currentState.isDirty | isDirty | one name instead of two, and it includes relationship changes |
changedAttributes() | changes | reactive, per field, and includes relationships |
isSaving, isError, adapterError | — | request state; use getRequestState / <Request> for the save request |
errors, isValid | — | validation errors arrive with the rejected request; cache.getErrors(key) remains |
isLoading, isLoaded, isReloading, isPreloaded | — | a PolarisMode resource is materialized from cache data; loading is a property of a request |
dirtyType, currentState.stateName | — | string-encoded restatements of the booleans above; stateName exposes the long-gone state machine |
currentState | — | $state is the state; there's no separate state machine object to reach into |
isDestroying, isDestroyed | — | intentionally omitted; see Removal from the store |
Removal from the store
LegacyMode records expose isDestroying and isDestroyed from their EmberObject lifecycle, and apps use them to tell that a record they still hold has been unloaded. $state intentionally omits both.
Those flags exist to support record-based save and loading patterns: calling record.save(), record.reload() or record.destroyRecord() on an instance, and guarding against a record that was torn down while one of those calls, or the UI around it, still held it. Record-based saving and loading are not part of PolarisMode. Resources are loaded and saved through requests, and it is the request (via getRequestState / <Request>) that reports whether it is pending, succeeded or failed. In a request-based paradigm, a destroy flag is much less necessary.
For the cases that remain, $state already reports what can be observed. When a PolarisMode record leaves the store:
store.unloadRecord(orunloadAll, or deleting a new record) calls the store'steardownRecordhook, which destroys the record instance synchronously and releases its notification subscriptions, including$state's.- The cache then releases the resource's data.
- The store stops returning that instance. If the same resource is loaded again, it gets a new instance; the old one stays disconnected.
A UI still rendering the torn-down instance sees $state.isEmpty become true (see isEmpty). Teardown is a single synchronous step, so there is no observable "destroying" phase that an isDestroying flag could report. A successful delete does not by itself unload the resource; the instance stays materialized with isDeletionCommitted: true, which is the flag for "this is gone on the server".
If we find that a utility for detecting a disconnected instance is still useful, for example an isDestroyed flag set at teardown that stays true even after the same resource is re-loaded into a new instance, one will be added at a later time.
TypeScript
ReactiveResourceState, ResourceFieldChange and FieldChange are exported as types from @warp-drive/core/reactive. Apps that type their resources by hand add $state the same way they add $type:
import type { ReactiveResourceState } from '@warp-drive/core/reactive';
interface User {
readonly id: string;
readonly $type: 'user';
readonly $state: ReactiveResourceState;
readonly name: string;
}Apps that generate types from their schemas (e.g. via the schema DSL) should get it from the same generator that emits $type. A generator could also narrow changes to the resource's own field names; see Unresolved questions.
Compatibility
$state is additive. Schemas not built with withDefaults are unaffected; schemas that are gain a field that is non-enumerable and so does not change serialization, spreading or Object.keys. A schema passed to withDefaults that already declares its own field named $state would now collide with the added one; see Unresolved questions.
LegacyMode schemas (built with withDefaults from @warp-drive/legacy/model/migration-support) are unchanged and keep their flat flags. $state is not added to them, so a resource migrating from LegacyMode to PolarisMode moves from record.isNew to record.$state.isNew in the same change that moves its schema. The table above is mechanical enough for the migration codemods to rewrite those reads, except for the request-state rows, which need the save request's state instead.
Ecosystem
- Lint rules: none required. A rule flagging LegacyMode flag reads (
record.isNew,record.hasDirtyAttributes) on PolarisMode resources would be a useful follow-up to the migration codemods. - DevTools / Inspector:
$state.toJSON()gives devtools a cheap, serializable summary of a resource's lifecycle, including the names of its changed fields, to show alongside its data. - SSR: no impact;
$stateholds no data that isn't already in the cache, so nothing extra needs to be serialized or rehydrated.
How we teach this
$state is taught as part of withDefaults: the @warp-drive/core/reactive module docs' "Utilities" section already describes what withDefaults adds (identity and $type), and $state joins it with a short example and a link to the ReactiveResourceState API page, which documents each property.
The guides on editing resources are where it becomes useful: an edit form that shows a "you have unsaved changes" prompt from $state.isDirty, marks each changed input from $state.changes[field] without re-rendering the whole form, and takes its saving and error state from the save request.
For users migrating from Model, the upgrade guide gains the "What is not carried over" table from this RFC, framed as "if you used X, use Y", with the request-state and loading-state rows pointing to getRequestState and the <Request> component.
The terminology deliberately matches LegacyMode wherever the meaning is unchanged, so an existing user's vocabulary carries over; the only rename (hasDirtyAttributes → isDirty) is one where the old name was both redundant and incomplete.
Drawbacks
- One more field on every PolarisMode resource. It is lazy and non-enumerable, so the cost is a schema entry until someone reads it, but it does become part of the default shape we have to support.
$statereports the resource, not the projection. An app that expected the immutable instance to be "clean" while its checkout is being edited will be surprised. We think the shared answer is the more useful one, but it is a choice.- Saving and error state moves to the request. Apps that relied on reading
isSavingorerrorsoff the record from anywhere in the UI now need the save request's state, which is only available where the request was issued or passed. changesreports cache values, not field values. A field with aTransformationreports the serialized form ('2026-09-25', not aDate), because that is what the cache stores and diffs.- A second vocabulary during migration. Apps midway through moving from LegacyMode will read
record.isNewon some resources andrecord.$state.isNewon others.
Alternatives
- Flat flags, as LegacyMode does. Putting
isNew,isDirty, ... directly on the resource would make migration a no-op for reads, but it spends many names in the resource's own namespace, collides with real attributes of the same names, and conflicts with PolarisMode's$-prefixed convention for metadata. - A standalone function, e.g.
getResourceState(record). This mirrorsgetRequestStateand needs no schema field, so it would also work for schemas not built withwithDefaults. It is less discoverable, doesn't show up in the resource's type, and still needs somewhere to keep the per-record state and subscription alive. It remains a reasonable addition on top of$state(backed by the same object), if schemas that opt out ofwithDefaultsneed it. changesas a list of changed field names, or a boolean per field. Simpler to type, but an edit form usually wants the original value too ("was: Chris"), and the cache already computes it.changeswith hydrated (transformed) values. Friendlier for fields with transformations, but it would run every transformation'shydrateon every read and diverge from what the cache reports throughchangedAttrs.- Reuse
RecordStatefrom@warp-drive/legacy. It would bring theErrorsArrayProxy,stateName, and the request and loading flags along with it, and would make@warp-drive/coredepend on@warp-drive/legacy. - Do nothing. Apps keep re-deriving these flags from the cache, usually without reactivity and without releasing their subscriptions.
Unresolved questions
- What
isEmptymeans for a resource loaded with an empty payload (deferred). This RFC leavesisEmptyas the cache reports it. The JSON:API cache'sisEmptyistrueonly when the resource has never received field data; a payload of just{ type, id }stores an empty set of attributes, soisEmptyreportsfalsefor it. Whether such a resource, which partial fields make more common, should count as empty may be an unresolved question to revisit at a later time. Changing it would mean changing the cache'sisEmpty, which the store'speekRecordalso uses to decide whether a resource is loaded. - Per-field validation errors. Should the cache's per-resource errors be surfaced per field, alongside
changes(e.g.$state.errors[field]), now that request-levelerrors/isValidare out of$state? changesforaliasfields. Analiasreads another field's cache data; shouldchanges[alias]mirror the entry for the field it aliases?- Typing
changes. ShouldReactiveResourceStatetake the resource type as a parameter, sochangesis keyed by the resource's own field names and each entry narrows toFieldChangeorRelationshipDiffby field kind? - Should
withDefaultsassert in development when a schema already declares a field named$state(or$type,$key), rather than silently overriding it? - Should
$statebe added to LegacyMode schemas as well, so that migrating code can switch to$statebefore the schema itself moves to PolarisMode?