ilokesto

Building on Store

Guidelines for adapter and library authors.

Building on Store

Build framework adapters, reusable libraries, controllers, or integration layers on top of @ilokesto/store by depending on Store's public contract.

These guidelines are for adapter and library authors rather than app-level snippets. App developers should start with Framework Usage for React, Vue, Svelte, and Angular examples, or Common Patterns for application recipes. Adapter code should stay on the stable boundary it can support over time.

API Reference lists exact method signatures. Advanced Semantics covers replacement, equality, notification, and listener rules.

Adapter design principles

Build the thinnest bridge that fits your target runtime.

Most adapters need to do four things:

  1. Read the current value with store.getState().
  2. Subscribe with store.subscribe(listener).
  3. Forward writes with store.setState(next) or your own action layer.
  4. Call the unsubscribe function when the owning runtime scope ends.

Keep Store as the source of truth. Let the framework or library layer own framework-specific concepts such as hooks, refs, signals, effects, selectors, validation, persistence, or devtools.

Public contract boundaries

Treat these methods as the boundary for adapter code:

  • getState() reads the current snapshot.
  • setState(next) replaces state with a value or updater result.
  • subscribe(listener) observes successful state replacements and returns cleanup.
  • getInitialState() reads the constructor value when your abstraction needs reset or bootstrap behavior.

Do not couple an adapter to private fields, listener storage, middleware arrays, or implementation helper names. If your library exposes Store directly, document that consumers are using the vanilla @ilokesto/store API. If your library wraps Store, expose only the methods and actions your abstraction can support over time.

import { Store } from "@ilokesto/store";

type ReadonlyStore<T> = {
  getState(): Readonly<T>;
  subscribe(listener: () => void): () => void;
};

export function readonlyView<T>(store: Store<T>): ReadonlyStore<T> {
  return {
    getState: () => store.getState(),
    subscribe: (listener) => store.subscribe(listener),
  };
}

This kind of wrapper lets a library hide writes without depending on internals.

Lifecycle mapping

Map each subscription to one owner in the target runtime. The owner might be a component, request, controller, test harness, or long-lived service.

import { Store } from "@ilokesto/store";

type StoreConnection<T> = {
  getSnapshot(): Readonly<T>;
  setState(nextState: T | ((prevState: Readonly<T>) => T)): void;
  destroy(): void;
};

export function connectStore<T>(store: Store<T>): StoreConnection<T> {
  let snapshot = store.getState();

  const unsubscribe = store.subscribe(() => {
    snapshot = store.getState();
  });

  return {
    getSnapshot: () => snapshot,
    setState: (nextState) => {
      store.setState(nextState);
    },
    destroy: unsubscribe,
  };
}

The framework-specific version of this shape belongs in Framework Usage. The same rule applies in every runtime: create the subscription when the owner starts, then clean it up when the owner stops.

Snapshot consistency

getState() is the authoritative snapshot reader. A subscription listener should read a fresh snapshot inside the listener instead of trusting a cached value.

Store notifications are synchronous after a successful replacement. If your target runtime batches or schedules updates, that scheduling belongs to the adapter layer. Do not describe Store itself as batched or async.

const unsubscribe = store.subscribe(() => {
  const nextSnapshot = store.getState();
  publishToRuntime(nextSnapshot);
});

If you add selectors, document where equality checks happen. Store only applies its own Object.is bailout to the root state value. Selector memoization is part of your adapter contract, not the core Store contract.

Cleanup guarantees

subscribe(listener) returns an unsubscribe function. Your abstraction should make that cleanup hard to forget.

Good adapter APIs usually do one of these:

  • return a destroy() or dispose() method,
  • register cleanup with the target runtime lifecycle,
  • scope the subscription to a controller that owns its teardown.
const connection = connectStore(store);

try {
  runWithConnection(connection);
} finally {
  connection.destroy();
}

Calling cleanup should stop future notifications for that listener. It should also be safe for consumers to call cleanup when the owner is done, even if no further updates happen.

Replacement-aware writes

setState replaces the whole state value. If your library exposes partial updates, the library must merge fields before calling setState.

function setPartial<T extends object>(store: Store<T>, partial: Partial<T>) {
  store.setState((prev) => ({ ...prev, ...partial }));
}

Make this choice explicit. Some libraries should expose replacement writes because that matches Store exactly. Others should expose domain actions or patch helpers because that is safer for their users.

Compatibility checklist

Before publishing an adapter or library, check that it follows the public contract.

  • Adapter reads snapshots through getState().
  • Public contract does not expose private fields or depend on internal listener storage.
  • Lifecycle ownership is clear for every subscription.
  • Snapshot updates read fresh state inside the listener.
  • Cleanup is returned, registered, or otherwise guaranteed.
  • Compatibility notes say whether writes are full replacements, action-only, or library-merged partial updates.
  • Tests cover multiple store instances so state does not leak across consumers.
  • Docs link app users to Framework Usage and Common Patterns, while keeping adapter guidance focused on author-facing contracts.

On this page