Mobile Computing · Cross-Platform Development

Shared State Without a Shared Heap

Two platform runtimes cannot hold the same objects, so cross-platform state lives in an external store with subscription and persistence semantics that the framework must define itself.

Application state in a cross-platform codebase has a constraint that a single-runtime application does not: there is no heap both halves can point into. The shared core holds the canonical state, the platform adapters need to read it to render, and the two cannot share object identity. State must therefore be published as values that cross a boundary, and every design question follows from that: when values are copied, who is notified, and what happens when the copy and the canonical state disagree.

The frameworks that solve this well converge on the same shape, which is a store holding an immutable snapshot, mutations expressed as descriptions rather than in-place edits, and subscriptions that deliver the new snapshot by value. The shape is not a matter of taste; it is forced by the boundary.

Why mutation in place cannot cross

Consider the naive design: the core holds a mutable model, and the adapter holds a reference to it. In a single-runtime application that works, because the reference is a pointer into the same heap and a mutation is visible immediately to every holder. Across a boundary, the reference the adapter holds is either a copy, in which case it never sees mutations, or a handle that must be dereferenced through the runtime on every field access, in which case each read is a crossing.

The second option is worse than it sounds. A rendering pass that reads 20 fields per node across 1,000 nodes is 20,000 crossings per frame, which the analysis in Marshalling Across the Language Boundary makes obviously unworkable. And even if it were fast, the ordering problem remains: a field read during a frame may observe a mutation that happened halfway through the pass, producing a screen assembled from two different states.

Snapshot publication fixes both. The core computes a new immutable value, converts it once at the boundary, and hands the converted value to the adapter, which reads it locally for the whole frame. One crossing per update instead of one per field, and a frame that is internally consistent by construction.

The store contract

The minimum contract has 4 operations and 1 guarantee.

type Listener<S> = (state: S) => void;

interface Store<S, A> {
  getState(): S;                      // current snapshot, never mutated
  dispatch(action: A): void;          // request a transition
  subscribe(l: Listener<S>): () => void;  // returns an unsubscribe function
}

// Reducers are pure: (state, action) -> state, and must not mutate `state`.
function reduce(state: Counter, action: CounterAction): Counter {
  switch (action.kind) {
    case 'increment':
      // A fresh object, not a mutated one; identity is what signals change.
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

The guarantee is that getState returns a value that will never change. That single property is what makes the snapshot safe to hand across a boundary: the adapter can hold it for a frame, or for a scroll gesture, without coordinating with the core. It also makes the equality check cheap, because a new snapshot can be compared by identity: if reduce returned the same object, nothing changed, and no subscriber needs to run.

The subscribe operation returns an unsubscribe function rather than requiring a separate removal call keyed by listener identity. This is not stylistic. A listener registered by a component that is destroyed must be removed, and a keyed removal requires the caller to hold the exact same function reference, which is easy to lose when the listener is a bound method or a closure created per render. Returning the remover makes the correct code the short one.

Notification is a fan-out problem

When a snapshot changes, every subscriber must be told. The naive implementation walks the listener list and calls each one synchronously, in registration order, inside dispatch, which produces 3 problems.

First, re-entrancy. A listener that dispatches during notification causes a nested notification, and the listener list is being iterated while it is being modified. The usual fix is to queue actions dispatched during notification and process them after the current notification completes, which preserves the invariant that a listener sees the state that caused its call.

Second, ordering. If two listeners each dispatch, the order of their effects depends on registration order, which is not a property any component controls. The fix is to make dispatch ordering a property of the reducer pipeline rather than of the subscriber list, which is what the queue above achieves.

Third, granularity. A listener subscribed to the whole state is called for every change, even when its slice is untouched. In a mobile application the cost is not the listener call itself but the work it triggers: a re-render, a layout, a diff. Selector-based subscription addresses this by comparing the selected slice between snapshots and skipping the call when the slice is unchanged by identity.

function subscribeSelected<S, T>(
  store: Store<S, unknown>,
  select: (s: S) => T,
  onChange: (value: T) => void,
): () => void {
  let previous = select(store.getState());
  return store.subscribe((state) => {
    const next = select(state);
    // Reference equality is the fast path; a selector that rebuilds an
    // object every call defeats it and must be memoised.
    if (!Object.is(previous, next)) {
      previous = next;
      onChange(next);
    }
  });
}

The requirement this places on reducers is that unchanged subtrees keep their identity. A reducer that rebuilds the whole state object on every action, deep-copying every branch, makes every selector return a new value and every subscriber fire. Structural sharing, where only the path from the root to the changed node is rebuilt, is what makes the identity check useful. The cost is that each update allocates the nodes along that path, proportional to the depth of the change rather than to the size of the state.

Persistence is part of the contract, not an add-on

A mobile application is killed without warning. State that matters must be written before the process goes away, and the platform gives limited notice. The store therefore needs a persistence rule, and the rule has to be decided when the store is designed because it constrains what state may contain.

There are 3 viable rules. Write on every dispatch, which is correct and expensive because it makes a disk write part of the interaction path. Write on a debounce, which loses the last interval’s changes on a kill. Write at lifecycle transitions, which loses everything if the transition is not delivered.

The choice interacts with the snapshot design. If state is a plain serialisable value, writing it is a single encode and a single write, and any of the three rules is implementable. If state contains objects with platform identity, open handles, or closures, it cannot be written at all, and the parts that can be written are scattered through it. This is the practical argument for keeping the canonical state a value: it is the only form that can be persisted, compared, and sent across a boundary without a custom serialiser for every type.

The storage port from The Portable Core Boundary is where the write lands. The store should not know the storage mechanism, but it must know the durability requirement, because the difference between “written” and “durable” is a call the adapter makes and a failure the store must handle. On Android, a write to internal storage is not durable until the file is synchronised, and the synchronisation is the expensive part; batching many state writes into one synchronisation is the standard mitigation and requires the store to expose a flush point.

There is also a versioning obligation. A persisted snapshot written by one application version may be read by the next, and the shape may have changed. The store needs a schema version in the persisted form and a migration path, or the rule that unreadable state is discarded. Discarding is acceptable for caches and unacceptable for user data, and the store must distinguish them, which usually means two stores with different durability rules rather than one store with a flag.

Time travel, replay, and the debugging dividend

Because transitions are values and state is a value, the entire history of the application is a list of actions. Recording that list allows replay: starting from an initial state and applying the actions reproduces the state exactly, provided reducers are pure. This is the property that makes the pattern worth its ceremony, because a bug report can carry the action list and the reproduction is deterministic.

The property fails when purity fails. A reducer that reads the current time, calls a random number generator, reads a file, or depends on a platform identifier produces different results on replay. The discipline is to put those effects outside the reducer: the action carries the timestamp, the random value, or the fetched data as a payload, and the reducer only arranges it. The core’s clock and randomness ports are what make this possible without ambient authority, which is the same argument as before.

Replay also has a cost that is easy to overlook: the action log grows without bound, and holding it in memory for a long session is a leak. Production builds typically keep a bounded ring of the last 1,000 actions, or none at all, and enable full logging only when a diagnostic mode is on.

What the store is not for

2 categories of state are commonly pushed into the shared store and should not be.

The first is ephemeral UI state: the scroll offset of a list, the current page of a pager, the animation progress of a transition. These change at frame rate, and routing them through dispatch and notification makes every frame a state transition with subscribers. They belong to the adapter, which can hold them in the platform’s own objects where updating them is a field write.

The second is derived state that can be computed cheaply. Storing a filtered list alongside the source list means two things must be kept consistent, and the consistency is a bug surface. Computing the filtered list in a selector, memoised on the identity of the inputs, keeps one source of truth and pays a computation whose cost is proportional to the list length per change rather than per read.

The dividing line is frequency and authority. State that changes at human interaction rate and that must survive a process death belongs in the store. State that changes at frame rate, or that is a pure function of other state, belongs elsewhere. Getting this wrong in either direction is the usual cause of a store that is either too slow or too large, and both are visible in the frame budget rather than in the code.

References

  1. Redux Documentation: Three Principles — Redux project
  2. Elm Architecture — Evan Czaplicki
  3. Dart: Isolates and Concurrency — Dart project