Skip to content

ConfigStore

ConfigStore is the central class in @skystate/core. It composes an HTTP client, an in-memory cache, and a pub/sub emitter into a single object that fetches config from the server, caches it locally, and notifies subscribers when values change.

In most cases you do not instantiate ConfigStore directly. Use getOrCreateStore instead, which handles environment resolution and returns a shared singleton.


getOrCreateStore

typescript
import { getOrCreateStore } from '@skystate/core';

const store = getOrCreateStore({
  apiUrl: 'https://api.skystate.io',
  accountSlug: 'my-account',
  projectSlug: 'my-project',
  resolveEnvironment: () => import.meta.env.MODE,
});

Options

typescript
interface ConfigStoreOptions {
  apiUrl: string;
  accountSlug: string;
  projectSlug: string;
  /**
   * Required resolver function. Called once to determine the environment.
   * Must return one of: 'development', 'staging', 'production'.
   */
  resolveEnvironment: () => string;
  /**
   * Optional initial config to seed the cache before the first HTTP fetch completes.
   */
  initialConfig?: unknown;
  /**
   * Optional custom X-SkyState-Client header value.
   */
  clientHeader?: string;
  /**
   * Optional dev API key. When present and the resolved environment is 'development',
   * enables auto-registration of missing keys.
   */
  devKey?: string;
}

Singleton behaviour

getOrCreateStore maintains a module-level registry keyed by ${apiUrl}|${accountSlug}|${projectSlug}|${environmentSlug}. Calling it with the same parameters returns the same ConfigStore instance. This means all hooks and components reading from the same project and environment share one HTTP connection and one cache.

A store is removed from the registry when store.dispose() is called. The next call to getOrCreateStore with the same parameters creates a fresh instance.

Environment validation

getOrCreateStore calls resolveEnvironment() once and validates the result. If the resolver throws or returns a value not in ['development', 'staging', 'production'], a SkyStateError is thrown synchronously before any HTTP request is made.


ConfigStore API

subscribe(path, callback)

typescript
subscribe(path: string, callback: () => void): () => void

Subscribe to changes at a dot-separated path. Returns an unsubscribe function.

The callback is called whenever the value at path (or any descendant) changes. The callback receives no arguments — call getSnapshot(path) inside the callback to read the new value.

subscribe and getSnapshot together satisfy the contract required by React's useSyncExternalStore.

typescript
const unsubscribe = store.subscribe('banner.text', () => {
  console.log('banner.text changed:', store.getSnapshot('banner.text'));
});

// Later, to clean up:
unsubscribe();

Special path __status: Subscribing to '__status' notifies you when isLoading, error, or lastFetched changes. This is the path used internally by useProjectConfig and useProjectConfigStatus.

getSnapshot(path)

typescript
getSnapshot(path: string): unknown

Return the current value at a dot-separated path. Returns undefined if the path does not exist in the config or if no data has been fetched yet.

An empty string ('') returns the entire config object.

The returned references are stable: if the value at path has not changed since the last fetch, the same object reference is returned. This is important for React rendering — useSyncExternalStore only re-renders when Object.is(prev, next) returns false.

typescript
const bannerText = store.getSnapshot('banner.text');  // 'Hello!'
const allConfig = store.getSnapshot('');              // { banner: { text: 'Hello!' }, ... }

isLoading

typescript
get isLoading(): boolean

true until the first fetch (successful or failed) completes. false thereafter, even if subsequent fetches are in progress.

error

typescript
get error(): Error | null

The last error encountered, or null if the most recent fetch succeeded. This is set on any HTTP error (non-2xx response) or network failure.

lastFetched

typescript
get lastFetched(): Date | null

The timestamp of the last successful fetch, or null if no successful fetch has completed yet.

registerIfMissing(path, defaultValue)

typescript
registerIfMissing(path: string, defaultValue: unknown): void

Called internally by useProjectConfig when a key is not found in the config and a fallback value was provided.

  • In development mode (with devKey): enqueues the key and default value for auto-creation via a batched PATCH request.
  • In production (no devKey): logs a console warning and returns without making any request.

dispose()

typescript
dispose(): void

Cleans up all resources: cancels scheduled re-fetches, aborts any in-flight HTTP request, removes the tab visibility listener, clears all pub/sub subscriptions, and removes the store from the singleton registry.

After calling dispose(), the store must not be used. If you call getOrCreateStore with the same parameters after disposing, a new instance is created.

typescript
// Typical cleanup pattern
const store = getOrCreateStore({ /* ... */ });

// On unmount or teardown:
store.dispose();

How Fetching Works

When a ConfigStore is created, it immediately starts an HTTP GET to:

GET {apiUrl}/public/{accountSlug}/{projectSlug}/config/{environmentSlug}

The response is a ConfigEnvelope:

typescript
interface ConfigEnvelope {
  version: { major: number; minor: number; patch: number };
  lastModified: string;  // ISO 8601 timestamp
  config: unknown;       // The JSON config object
}

After a successful fetch, the store reads the Cache-Control: max-age=N header from the response and schedules a re-fetch after N seconds. The interval is server-controlled — no polling interval is configured in the client.

If the tab becomes hidden and then visible again after the max-age has elapsed, the store re-fetches immediately rather than waiting for the next scheduled timer.


Dot-path Notation

Config keys are read and subscribed using dot-separated paths that match the structure of the JSON config object.

Given this config:

json
{
  "banner": {
    "enabled": true,
    "text": "Hello!"
  },
  "features": {
    "maxItems": 5
  }
}
PathValue
'' (empty string)The entire config object
'banner'{ enabled: true, text: 'Hello!' }
'banner.enabled'true
'banner.text''Hello!'
'features.maxItems'5
'features.missing'undefined

Subscriptions also use dot-paths. A subscription to 'banner' is notified when either banner.enabled or banner.text changes. A subscription to '' is notified when anything changes.


initialConfig

You can seed the cache with an initial value before the first HTTP fetch completes. This is useful for server-side rendering (SSR) or for providing defaults when you have a known baseline config:

typescript
const store = getOrCreateStore({
  apiUrl: 'https://api.skystate.io',
  accountSlug: 'my-account',
  projectSlug: 'my-project',
  resolveEnvironment: () => 'production',
  initialConfig: {
    banner: { enabled: false, text: '' },
    features: { maxItems: 5 },
  },
});

The initial config is treated as a synthetic version 0.0.0. When the first real fetch completes, the cache is updated and subscribers are notified of any values that differ from the initial config.

Built with VitePress