3  Values and State: Editing, Snapshots, and Reference Owners

3.1 R’s physics: copy-on-modify

R usually presents a value-oriented interface. What looks like mutation — x[1] <- 5, df$col <- z, style@zoom <- 10 — is replacement followed by rebinding. The @<- form is replacement syntax as well. R may reuse or copy memory internally, and those implementation details are not a public promise of structural sharing.

For ordinary value-like payloads, revising an S7 object through replacement produces a new value for the receiving binding; another binding ordinarily continues to denote the prior value. This is editable value semantics, not field immutability. A package can additionally expose a value as a read-only snapshot by withholding public update operations, but that is an API policy rather than a consequence of S7.

Validation hooks into supported replacement paths. S7 runs validation after property replacement, so exported transformation functions can preserve the class contract. The guarantee is bounded: a validator certifies only rules it can see, and mutation inside a reference-valued property does not invoke the outer object’s validator. This is the mechanical foundation for the parse-don’t-validate discipline in Parse, Don’t Validate.

3.2 Why value semantics are a superpower for domain modeling

Rich Hickey’s “value of values” argument is useful beyond its Clojure origins (Hickey 2012). A representation whose observable domain content changes only through replacement supports local reasoning. For domain modeling, values buy:

  • Focused testing. Value-in, value-out functions support assertions about domain equality and serialized form without hidden lifecycle setup.
  • Explicit history. Keeping an earlier binding preserves a prior logical version. Storage cost depends on R’s implementation and payload; history is not universally free.
  • Branching. Two candidate variants of a configuration are two values. Nothing needs cloning ceremonies; ellmer’s R6 Chat needs an explicit $clone() to fork a conversation precisely because it opted out of value semantics.
  • Compositional reasoning. Transformations compose as functions. A pipeline style |> add_layer(l) |> set_zoom(10) makes each revision explicit.

The temptation to resist is making domain objects mutable “because updating is annoying.” The annoyance is real (deep updates in value-land involve rebuilding paths through the structure) but it is the cost side of every guarantee listed above, and R’s replacement-function machinery plus well-designed mutator generics (add_layer(style, layer) returning a new style) absorbs most of it.

3.3 Three lifecycle policies

The easiest way to see the lifecycle choices is to follow one concept through time. Imagine a style being assembled in an editor. While it is a draft, adding a layer or changing a zoom range is lawful, but each operation can return a revised value. The caller can keep the prior draft, compare two candidates, or abandon one branch without coordinating aliases. This is an editable value: editable because the API supplies transformations, a value because those transformations use replacement rather than shared mutation.

When the style is published, the package may expose it as a read-only snapshot. Nothing about R’s copy mechanics has changed. The difference is policy: a completed fact has no public revision operation. If correction is needed, the program can create a new draft from the snapshot and eventually publish another snapshot, preserving which fact was current at each point.

The editor that coordinates drafts and publication is different again. It may own a live connection, autosave timer, undo cursor, and subscriptions that must all refer to the same ongoing session. That object is a reference owner. Its aliases are expected to observe the same evolving lifecycle, so an environment or R6 object expresses the concept more honestly than repeated value replacement.

The progression—draft to snapshot under the control of an owner—shows why “mutable or immutable?” is too blunt. Ask instead: what changes are lawful, who owns them, should an earlier fact remain available, and must other observers see a change immediately? Configuration, documents under construction, and query descriptions often begin as editable values; completed turns, measurements, and published documents often become snapshots; sessions, connections, and editors are reference owners.

3.4 Where reference semantics genuinely belong

Value semantics are the wrong answer for a specific, identifiable class of things: those with identity and a lifetime — where the whole point is that observers share the same evolving thing.

  • A conversation being appended to, turn by turn.
  • A connection or session with a server, a file handle, a browser tab.
  • A cache or memoization table, whose entire purpose is coordinated mutation.
  • A registry that accumulates plugins or handlers at load time.
  • UI state observed by a reactive framework.

R expresses these with environments, R6, or external pointers. The design failure mode is not using reference semantics — it is using them where shared identity is not part of the concept. A completed turn, measurement, or published style can be a read-only snapshot. A draft style can be an editable value. The conversation or editor coordinating those values may be a reference owner.

One qualification deserves stating as a law of its own, because it bounds every claim above: value semantics is only as deep as the payload. An S7 object behaves dependably as a value only when everything reachable through its properties is itself value-like. A property holding an environment, an R6 object, an external pointer, or a connection is a reference inside a value: copying the object copies the pointer, not the contents, and every “copy” observes mutations of the shared payload. This is directly demonstrable — assign into an environment held as a property through one binding, and the change is visible through the other binding, even after the two objects have otherwise diverged. The wrapper does not immunize its contents, and changing the shared payload does not re-run the wrapper’s validator. If a class must hold a reference, document the aliasing and lifecycle contract, define whether the reference participates in domain equality or serialization, or snapshot the referenced state into plain data at the boundary.

3.5 Process boundaries are serialization boundaries

Parallel workers do not turn reference-valued properties into shared state (Bengtsson 2026a, 2026b). PSOCK and future-style workers receive serialized copies of exported values; an environment copied to another process is a different environment, while a connection or external pointer may not be usable there at all. Forked workers inherit another set of hazards around live connections and foreign resources.

Pass portable S7 values across process boundaries when their payload is serializable, ensure the defining package and method registrations are available on the worker, and reconstruct live resources inside the worker from declarative specifications. During development, reference scanning such as options(future.globals.onReference = "error") can expose accidental external pointers. The architecture is the same as at a service boundary: send meaning as data; bind capabilities and resources where they will run.

3.6 Functional core, imperative shell

Gary Bernhardt’s “Boundaries” talk (Bernhardt 2012) gave the canonical name to the architecture these observations imply: a functional core of pure functions over values, wrapped in a thin imperative shell that talks to the world. The shell fetches, reads, writes, streams, and mutates session state; the core decides, transforms, constructs, and validates. Values cross the boundary; decisions concentrate in the core where they are trivially testable; side effects concentrate in the shell where they are at least localized.

In R terms:

  • Core: S7 constructors and validators; pure transformations from object to object; serialization to and from plain lists; generation of documents, queries, expressions.
  • Shell: httr2 requests, file I/O, database and GDAL calls, Shiny reactivity, LLM streaming, R6 orchestrators.

ellmer is the cleanest published illustration. The R6 Chat object is the shell: it owns the HTTP lifecycle, the streaming, the tool-invocation loop, and the accumulating list of turns — a state machine with identity. Everything that flows through it is S7 values: Turn objects containing Content objects, a Model configuration, a Provider description. The values are what you inspect, test, serialize, and reprocess; the R6 object is how the process is driven. Neither system is asked to do the other’s job.

The architecture has a fully formalized variant worth knowing by name: the Model–View–Update (Elm) architecture, which the tooey terminal-UI framework brings to R. An application is organized around three callbacks: init() creates its initial state, update(model, msg) maps the current state and an incoming message to new state plus commands, and view(model, buf) renders state into a screen buffer. The framework runtime owns the terminal, clock, and keyboard input, delivers them as plain-data messages, and executes returned commands.

That architecture encourages a functional core, but ordinary R callbacks can still read global state or perform I/O; the framework cannot enforce purity. Purity is therefore an application discipline: keep effects in declared commands and runtime adapters so init(), update(), and view() remain deterministic and independently testable. Beyond UI, MVU answers a question value-oriented designs eventually face — where do events go? Events are values too: represent them explicitly and fold them into state through the update operation.

The test for whether you have the boundary right: could you reconstruct the entire domain state from the values alone, replaying or discarding the shell? If domain knowledge exists only as mutable state inside a reference object, the boundary has leaked.

3.7 A taxonomy of state

“State” is not one thing. Separating its kinds prevents both over-engineering (R6 everywhere) and under-engineering (globals in disguise).

Document state — the current value of the domain object itself. Lives in S7 values; changes by replacement through constructors and mutator generics.

Derived state — anything computable from document state: an index from layer id to layer, a count, a rendered preview. The default is to not store it: compute on read, possibly via a getter-backed dynamic property. If computation is expensive, memoize outside the object (an environment keyed by a canonical domain representation). A cache inside the object introduces reference semantics and must be reflected in its aliasing, equality, and serialization contracts.

Collection state — registries of named things inside a document (sources by id, items by key). These are just properties (named lists) of an aggregate object; their invariants (unique ids, valid references) belong to that aggregate’s validator, as developed in Parse, Don’t Validate.

Session state — the live process: connections, conversations, event loops. R6 or environments, in the shell.

Configuration state — options, environment variables, credentials. Read at the boundary and passed in as values; a core function that consults options() mid-computation has smuggled in a hidden input.

3.8 Grouped replacements preserve relationships

S7 validates after each property replacement. When an invariant spans several properties, sequential single-property updates can pass through transiently invalid intermediate states — and the validator will correctly reject the intermediate, even though the final state would have been fine. Suppose a class requires start <= end and both must move forward. This looks like an ordinary pair of edits:

x@start <- 15
x@end <- 20

If x currently spans 1 to 3, the first line proposes the interval 15 to 3. The class validator cannot know that another statement is coming; it sees a complete proposed object that violates start <= end, so it rejects the replacement. Reversing the statements happens to work for this direction of movement, but fails symmetrically when both endpoints move backward. The problem is not statement order. Two values that must change together are being presented as two independent transitions.

The operation should instead construct one proposed state and validate the relationship once. S7::set_props() does that. The following executable example also keeps an alias to make the value behavior visible:

Interval <- S7::new_class(
  "Interval",
  properties = list(
    start = S7::class_double,
    end = S7::class_double
  ),
  validator = function(self) {
    if (self@start > self@end) {
      "@start must not exceed @end"
    }
  }
)

original <- Interval(start = 1, end = 3)
alias <- original
revised <- S7::set_props(original, start = 15, end = 20)

data.frame(
  binding = c("alias", "revised"),
  start = c(alias@start, revised@start),
  end = c(alias@end, revised@end)
)
  binding start end
1   alias     1   3
2 revised    15  20

The result demonstrates two independent facts. alias still records the original interval, because revising the receiving binding did not mutate every alias. revised contains the jointly updated endpoints, because validation saw 15 and 20 together. The operation is transaction-like: related replacements are checked as one proposed value rather than exposed as invalid intermediate values.

The design rule that follows is: any invariant spanning multiple properties implies that every supported code path updating those properties should use one grouped replacement—usually wrapped in a domain transformation such as move_interval(x, start, end). The wrapper names the transition and prevents callers from having to remember which fields form a validation unit.

S7 also provides S7::valid_eventually() for a transformation that must perform several ordinary replacements before the object becomes valid again. It suppresses intermediate validation and validates the returned object once at the end. Prefer S7::set_props() when the change is only a grouped assignment; valid_eventually() is for a genuinely multi-step transformation. Its lower-level companion, S7::valid_implicitly(), skips even the final validation. That transfers the entire proof obligation to the caller and belongs only in measured, performance-critical internals whose validity argument is explicit and tested.

3.9 Getters, setters, and the discipline of cheap access

S7 properties may carry a getter (making the property computed and, without a setter, read-only — and excluded from the default constructor, since it is not stored state) and a setter (which receives self and value and returns the modified self, enabling normalization on write).

Two contracts deserve emphasis because S7 does not enforce them for you. First, a getter’s return value is not validated against the declared class; you are on your honor. Second, and more important as a design matter: property access must be cheap and pure. The entire mental model of an S7 object is “structured data.” A getter that performs I/O, network calls, or expensive computation silently converts data access into action-at-a-distance and destroys the reader’s ability to reason about cost or effects. Computed properties are for derivations (a duration from start and end, a full name from parts, a constant discriminator), not for laziness tricks.

Setters, symmetrically, are for normalization that preserves meaning — trimming whitespace, sorting a tag set, upgrading a bare scalar to a length-one vector. The moment a setter starts making semantic decisions or enforcing cross-property rules, it is doing a constructor’s or validator’s job in a place readers will not look.

3.10 Identity, equality, and testing

Equality is a domain decision, not a synonym for identical().

  • Domain equality asks whether two values mean the same thing.
  • Canonical equality asks whether their normalized representations agree.
  • identical() asks whether two R objects have the same exact representation, including types and relevant attributes.
  • Reference identity asks whether two handles designate the same live owner.

These relations can disagree: 8 and 8L may be domain-equal but are not identical(), and two independently created values may render the same document despite incidental representational differences. Tests should name the relation they intend. Value transformations favor input/output laws and canonical projections; reference owners additionally need lifecycle and alias-observation tests.