Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Initialize Once, Hydrate Deliberately: Choosing a FeatureCell's Starting State

A FeatureCell does not start with "whatever was in the constructor." Calling initialize() activates the runtime pipeline, and the value it begins with depends on a resolved decision between a declared default, a previous

A FeatureCell does not start with "whatever was in the constructor." Calling initialize() activates the runtime pipeline, and the value it begins with depends on a resolved decision between a declared default, a previously persisted value, and a deliberate hydrate() factory. Knowing which source wins — and why — is what separates an accidental starting value from an intentional one.

This post walks through what initialize() actually activates, when hydrate() overrides everything else, and how to choose between a static default, Persist behaviors, and an explicit factory when your FeatureCell comes online.

Key takeaway: Initialization is not a direct mutation. Whatever value is selected — hydrated, persisted, or declared — is enqueued as a replaceState operation and processed through the same pipeline as any other update.

Initialization Is a Runtime Boundary

A FeatureCell is not operational the moment it is declared. It becomes a runtime participant only after initialize() is called — once, and exactly once. Reading state, issuing updates, or interacting with a FeatureCell before initialization results in a runtime error, and calling initialize() a second time does the same.

That boundary matters because initialization is where configuration becomes behavior. Filters, reducers, taps, error handlers, and emit configuration all have to be declared on the fluent builder before initialize() runs. Once initialization completes, the pipeline is fixed for the lifetime of that FeatureCell.

What initialize() Actually Activates

Calling initialize() transitions a FeatureCell from a configured definition into an active runtime participant. In order, it validates the declared configuration and behaviors, registers the FeatureCell with the Vault runtime, activates the Conductor execution surface, evaluates whichever initialization source was declared, and — if that source produced a value — enqueues it as the first replaceState operation.

If no initialization mechanism is declared at all, the FeatureCell still becomes active, but with value: undefined, waiting for an explicit replaceState() call. Declaring a default is optional; becoming active is not.

export const employeeCell = FeatureCell<Employee[]>({
  key: 'employees',
  // Initial state is declared directly in the descriptor
  initialState: [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]
});

employeeCell.initialize();

Here, descriptor.initialState is the only declared source, so that array becomes the first candidate through Resolve, Filters, Reducers, and Emit — the same pipeline any later update travels through.

When hydrate() Wins

During initialization, Vault evaluates exactly one initialization source, in strict precedence order:

  1. hydrate() — highest precedence
  2. Persist behaviors
  3. descriptor.initialState
  4. No source declared — manual initialization

These are not fallback layers. Vault does not try hydrate(), fail, and then fall back to Persist or the descriptor. Only the single highest-precedence declared source is evaluated at all.

hydrate(factory) registers a deferred factory that does not run when it is called — it runs only when initialize() runs. The factory may return a value synchronously, return a Promise, resolve to undefined, or throw. When declared, it overrides both Persist behaviors and descriptor.initialState outright, because declaring hydrate() is an explicit statement that persisted or static data should not be treated as authoritative for this initialization cycle.

export const employeeCell = FeatureCell<Employee[]>(
  {
    key: 'employees',
    // ignored
    initialState: []
  }
);

employeeCell.hydrate(() => Promise.resolve(...)).initialize();

⚠️ Warning: That descriptor still declares initialState: [], but once hydrate() is registered, it is never evaluated. Declaring both is not a layering strategy — only one source wins.

Choosing Between Defaults, Persistence, and Hydration

The practical question at startup is simple: should this FeatureCell begin with a declared default, a persisted value, or an explicit factory? Each source answers a different need.

Source Nature Choose it when
descriptor.initialState Static, declared at configuration time The starting value is fixed and known in advance, with no runtime dependency
Persist behaviors Previously committed state, restored from storage Continuity across reloads or sessions matters more than a guaranteed-fresh value
hydrate() Deferred factory, evaluated only during initialize() Startup state must be computed or fetched, and stale persisted data or a static default would be wrong

Persisted storage can go stale the moment the underlying data changes elsewhere, and a static descriptor is static by definition. hydrate() exists for the case where neither of those should be treated as authoritative — if dynamic resolution is not actually needed, do not declare it, and Vault evaluates storage or the descriptor according to precedence instead.

Handling Missing Values and Failures

A deferred factory does not always resolve the way you expect, and hydrate() treats "no value" very differently from "something went wrong."

Factory result What happens
Returns a value Enqueued as replaceState and processed through Resolve, Operators, Filters, Reducers, and Emit
Resolves to undefined No write occurs; the FeatureCell remains with value: undefined and hasValue: false
Throws or rejects Initialization fails; no fallback source is evaluated; the error is emitted

An undefined result is not treated as a failure — it just means nothing committed. A thrown error or rejected Promise is treated as a failure, because the declared authoritative source did not resolve the way the application promised it would.

export const employeeCell = FeatureCell({
  key: 'employees'
});

employeeCell.hydrate(() => fetch('/api/employees').then((res) => res.json())).initialize();

If that fetch rejects, initialization fails: hasValue stays false, value stays undefined, and error is populated. The pipeline keeps running — it does not crash the application — but nothing committed as declared. Recovery, if you want it, has to be explicit, using reset() and replaceState() to put the FeatureCell into a known state on your own terms.

⚠️ Warning: If hydrate() throws, Vault does not quietly drop down to Persist behaviors or descriptor.initialState. The FeatureCell does not activate partially, and the failure is visible instead of masked.

A live, interactive StackBlitz demo of hydrate() overriding a descriptor default is available on the original article if you want to try the sequence yourself.

Deeper Dive

Read the full method contracts for initialize() and hydrate() in the FeatureCell API reference, including the full precedence rules, diagrams, and resolution outcome tables this post draws from.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.