Skip to content

Framework Wrapper Guide

This document specifies how to build a SkyState framework wrapper for a JavaScript or TypeScript framework — Vue, Svelte, Angular, Solid, Preact, or any other reactive UI library.

The canonical wrapper is @skystate/react, which you can use as a reference implementation alongside this guide.


What @skystate/core Provides

@skystate/core implements the full Wire Protocol and exposes a ConfigStore that your wrapper builds on. You do not make HTTP requests, manage polling timers, or implement auto-provisioning in the wrapper layer — the core handles all of that.

ConfigStore interface

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

interface ConfigStore {
  // Read the current value at a dot-separated path.
  // Returns undefined if the path does not exist or the initial fetch has not completed.
  getSnapshot(path: string): unknown;

  // Subscribe a callback to a path. Returns an unsubscribe function.
  // The callback is called with no arguments whenever the value at path (or any descendant) changes.
  // Subscribing to '' (empty string) notifies on any change.
  // Subscribing to '__status' notifies when isLoading, error, or lastFetched changes.
  subscribe(path: string, callback: () => void): () => void;

  // In dev mode: enqueue path+defaultValue for auto-provisioning via PATCH.
  // In production: log a console warning only.
  registerIfMissing(path: string, defaultValue: unknown): void;

  // Status accessors — read after subscribing to '__status'
  readonly isLoading: boolean;
  readonly error: Error | null;
  readonly lastFetched: Date | null;

  // Clean up all resources. Must be called when the provider unmounts.
  dispose(): void;
}

getOrCreateStore returns a singleton keyed by (apiUrl, accountSlug, projectSlug, resolvedEnvironment). Calling it multiple times with the same parameters returns the same instance.

ConfigStoreOptions

typescript
interface ConfigStoreOptions {
  apiUrl: string;
  accountSlug: string;
  projectSlug: string;
  resolveEnvironment: () => string;  // Must return 'development' | 'staging' | 'production'
  initialConfig?: unknown;
  clientHeader?: string;             // e.g. '@skystate/vue/0.1.0'
  devKey?: string;                   // Read from environment at build time
}

The Five Rules

Every framework wrapper must follow these five rules. They are not optional — violating any of them will cause subtle bugs in production.

Rule 1 — Create once

Create the ConfigStore exactly once per provider lifetime. Do not recreate the store on re-renders, reactive updates, or prop changes. Use your framework's equivalent of a constructor or onMount — whatever runs once.

getOrCreateStore is safe to call multiple times (it returns the singleton), but the call to resolve environment and the DI / context registration must happen once.

Rule 2 — Inject via dependency injection or context

Do not export the ConfigStore instance as a module-level global. Provide it through your framework's component tree:

  • React: createContext / useContext
  • Vue: provide / inject
  • Angular: Injectable service
  • Svelte: setContext / getContext
  • Solid: createContext / useContext

This ensures that test environments can inject a mock store, and that SSR can isolate store instances per request.

Rule 3 — Wire subscribe to reactive primitives

Your framework's reactive system (signals, observables, refs, runes) must be driven by ConfigStore.subscribe. When the store calls your callback, read the new value with getSnapshot and push it into the reactive primitive.

Never poll the store. The store's pub/sub is the only notification mechanism.

Rule 4 — Unsubscribe on destroy

Every subscribe call returns an unsubscribe function. Call it when the component unmounts or the effect is cleaned up. Failure to unsubscribe causes memory leaks and stale callbacks that fire after unmount.

Rule 5 — Call dispose() on provider unmount

When the root provider component unmounts, call store.dispose(). This cancels HTTP timers, aborts in-flight requests, and removes the store from the singleton registry.

Do not call dispose() on individual hook/composable teardown — only on provider teardown. Individual hooks unsubscribe via their unsubscribe functions, not via dispose().


Provider

Every wrapper must include a provider component that:

  1. Accepts the connection parameters (apiUrl, accountSlug, projectSlug) as props.
  2. Optionally accepts a custom resolveEnvironment function — fall back to a framework-idiomatic default if omitted.
  3. Optionally accepts an envMap for mapping non-standard environment values to SkyState slugs.
  4. Reads devKey from the build environment (not as a prop — it must not be committed to source code).
  5. Creates the ConfigStore once.
  6. Injects it into the component tree via the framework's context mechanism.
  7. Calls store.dispose() on unmount.

Provider props

typescript
interface SkyStateProviderProps {
  /** Base URL of the SkyState API */
  apiUrl: string;
  /** Account slug */
  accountSlug: string;
  /** Project slug */
  projectSlug: string;
  /** Custom environment resolver. Falls back to the framework default if omitted. */
  resolveEnvironment?: () => string;
  /**
   * Map of raw environment values to SkyState slugs.
   * Applied after resolveEnvironment() returns its raw value.
   * Example: { "test": "development", "preview": "staging" }
   */
  envMap?: Record<string, string>;
}

Reading devKey from the build environment

The devKey must be read from the build-time environment, not passed as a prop. Passing it as a prop would expose it in source code. The canonical approach:

typescript
// Try import.meta.env (Vite/Rollup/esbuild)
let devKey: string | undefined;
try {
  const meta = import.meta as ImportMeta & { env?: Record<string, string> };
  devKey = meta.env?.SKYSTATE_DEV_KEY;
} catch { /* not available */ }

// Fallback: process.env (Node.js/webpack)
if (!devKey) {
  try {
    const g = globalThis as typeof globalThis & { process?: { env?: Record<string, string> } };
    devKey = g.process?.env?.SKYSTATE_DEV_KEY;
  } catch { /* not available */ }
}

Injecting projectSlug at build time

For applications where projectSlug is known at build time and never changes at runtime, wrappers may document a pattern for injecting it from an environment variable so the prop can be omitted from component code:

typescript
const projectSlug = import.meta.env.VITE_SKYSTATE_PROJECT ?? props.projectSlug;

This is a convenience pattern only — the prop-based approach remains the canonical API.


Hook / Composable Signatures

useProjectConfig — read-only config values (V1)

This is the primary consumer hook. It reads a single value from the project config and re-renders whenever the value changes.

typescript
// With fallback — data is T (never undefined)
function useProjectConfig<T>(
  path: string,
  fallback: T,
): { data: T; isLoading: boolean; error: Error | null }

// Without fallback — data may be undefined
function useProjectConfig<T = unknown>(
  path?: string,
): { data: T | undefined; isLoading: boolean; error: Error | null }

Behaviour:

  • Subscribe to path in the store. Re-render (or update the reactive primitive) when the value at path changes.
  • Subscribe to '__status' in the store. Re-render when isLoading or error changes.
  • If path is not provided or is an empty string, return the entire config object.
  • If the value at path is undefined and a fallback was provided, return fallback as data.
  • After the initial load completes, if the value is still undefined and a fallback was provided, call store.registerIfMissing(path, fallback) once (idempotent — the store deduplicates attempts).
  • The returned data reference must be stable when the value has not changed (structural sharing). Returning a new object reference on every tick will cause unnecessary re-renders.

useUserState — read/write per-user state (V2)

Not yet implemented in the public API. This section specifies the intended interface for future implementations.

Per-user state is mutable data associated with a user session rather than a project config. Writes go to a different endpoint from project config writes. The hook signature mirrors useProjectConfig but adds a patch function.

typescript
function useUserState<T>(
  path: string,
  fallback: T,
): {
  data: T;
  isLoading: boolean;
  error: Error | null;
  patch: (operations: JsonPatchOperation[]) => Promise<void>;
}

Behaviour:

  • Same subscription model as useProjectConfig.
  • patch applies optimistic updates locally before sending to the server.
  • On server rejection (non-conflict), roll back the optimistic update and surface the error.
  • On 409 Conflict, re-fetch the current state, discard the optimistic update, and surface a conflict error to the caller.

useSessionState — read/write ephemeral state (V3)

Not yet implemented in the public API. This section specifies the intended interface for future implementations.

Session state is ephemeral state that lives for the duration of a WebSocket connection. The hook additionally exposes a conflict field.

typescript
function useSessionState<T>(
  path: string,
  fallback: T,
): {
  data: T;
  isLoading: boolean;
  error: Error | null;
  conflict: ConflictInfo | null;
  patch: (operations: JsonPatchOperation[]) => Promise<void>;
  resolveConflict: (strategy: 'accept-remote' | 'retry-local') => Promise<void>;
}

Implementation Examples

React

React's useSyncExternalStore is the correct primitive for wiring an external subscription-based store to React's rendering model. Do not use useState + useEffect — it has a tearing risk on concurrent renders.

tsx
// context.tsx
import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react';
import { getOrCreateStore, type ConfigStore } from '@skystate/core';

const SkyStateContext = createContext<ConfigStore | null>(null);

export function SkyStateProvider({
  apiUrl,
  accountSlug,
  projectSlug,
  resolveEnvironment,
  children,
}: {
  apiUrl: string;
  accountSlug: string;
  projectSlug: string;
  resolveEnvironment?: () => string;
  children: ReactNode;
}) {
  const storeRef = useRef<ConfigStore | null>(null);

  // Eager init — NOT inside useEffect, so the store is ready on first render.
  if (storeRef.current === null) {
    // Read devKey from build environment — not from props
    let devKey: string | undefined;
    try {
      const meta = import.meta as ImportMeta & { env?: Record<string, string> };
      devKey = meta.env?.SKYSTATE_DEV_KEY;
    } catch { /* ignore */ }

    const resolver = resolveEnvironment ?? (() => {
      try {
        const meta = import.meta as ImportMeta & { env?: { MODE?: string } };
        if (meta.env?.MODE) return meta.env.MODE;
      } catch { /* ignore */ }
      const g = globalThis as typeof globalThis & { process?: { env?: { NODE_ENV?: string } } };
      return g.process?.env?.NODE_ENV ?? '';
    });

    storeRef.current = getOrCreateStore({
      apiUrl,
      accountSlug,
      projectSlug,
      resolveEnvironment: resolver,
      clientHeader: '@skystate/react/0.1.0',
      devKey,
    });
  }

  // Dispose on unmount only — not on re-render
  useEffect(() => {
    return () => {
      storeRef.current?.dispose();
      storeRef.current = null;
    };
  }, []);

  return (
    <SkyStateContext.Provider value={storeRef.current}>
      {children}
    </SkyStateContext.Provider>
  );
}

export function useStore(): ConfigStore {
  const store = useContext(SkyStateContext);
  if (!store) throw new Error('useProjectConfig must be used within a SkyStateProvider');
  return store;
}
tsx
// use-project-config.ts
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { useStore } from './context.js';

export function useProjectConfig<T>(
  path?: string,
  fallback?: T,
): { data: T | undefined; isLoading: boolean; error: Error | null } {
  const store = useStore();
  const resolvedPath = path ?? '';

  const subscribe = useCallback(
    (cb: () => void) => store.subscribe(resolvedPath, cb),
    [store, resolvedPath],
  );
  const getSnapshot = useCallback(
    () => store.getSnapshot(resolvedPath),
    [store, resolvedPath],
  );

  // useSyncExternalStore: no tearing, compatible with concurrent rendering
  const rawValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
  const data = (rawValue === undefined ? fallback : rawValue) as T | undefined;

  // Status
  const statusSubscribe = useCallback((cb: () => void) => store.subscribe('__status', cb), [store]);
  const getIsLoading = useCallback(() => store.isLoading, [store]);
  const getError = useCallback(() => store.error, [store]);
  const isLoading = useSyncExternalStore(statusSubscribe, getIsLoading, getIsLoading);
  const error = useSyncExternalStore(statusSubscribe, getError, getError);

  // Auto-registration (idempotent — store deduplicates)
  useEffect(() => {
    if (path && fallback !== undefined && rawValue === undefined && !isLoading) {
      store.registerIfMissing(path, fallback);
    }
  }, [store, path, fallback, rawValue, isLoading]);

  return { data, isLoading, error };
}

Svelte 5 (runes)

Svelte 5 introduces runes ($state, $derived, $effect) which are the correct primitive for external store integration.

svelte
<!-- SkyStateProvider.svelte -->
<script lang="ts">
  import { setContext, onDestroy } from 'svelte';
  import { getOrCreateStore, type ConfigStore } from '@skystate/core';

  interface Props {
    apiUrl: string;
    accountSlug: string;
    projectSlug: string;
    resolveEnvironment?: () => string;
    children: import('svelte').Snippet;
  }

  let { apiUrl, accountSlug, projectSlug, resolveEnvironment, children }: Props = $props();

  let devKey: string | undefined;
  try {
    devKey = import.meta.env?.SKYSTATE_DEV_KEY;
  } catch { /* ignore */ }

  const store = getOrCreateStore({
    apiUrl,
    accountSlug,
    projectSlug,
    resolveEnvironment: resolveEnvironment ?? (() => import.meta.env?.MODE ?? ''),
    clientHeader: '@skystate/svelte/0.1.0',
    devKey,
  });

  setContext('skystate', store);

  onDestroy(() => store.dispose());
</script>

{@render children()}
typescript
// useProjectConfig.svelte.ts
import { getContext } from 'svelte';
import type { ConfigStore } from '@skystate/core';

export function useProjectConfig<T>(path?: string, fallback?: T) {
  const store = getContext<ConfigStore>('skystate');
  const resolvedPath = path ?? '';

  // $state rune: reactive primitive
  let rawValue = $state<unknown>(store.getSnapshot(resolvedPath));
  let isLoading = $state(store.isLoading);
  let error = $state<Error | null>(store.error);

  // Wire store subscription to $state updates
  $effect(() => {
    const unsubValue = store.subscribe(resolvedPath, () => {
      rawValue = store.getSnapshot(resolvedPath);
    });
    const unsubStatus = store.subscribe('__status', () => {
      isLoading = store.isLoading;
      error = store.error;
    });
    return () => {
      unsubValue();
      unsubStatus();
    };
  });

  const data = $derived((rawValue === undefined ? fallback : rawValue) as T | undefined);

  return {
    get data() { return data; },
    get isLoading() { return isLoading; },
    get error() { return error; },
  };
}

Vue 3

Vue's ref and inject are the natural primitives. Use onUnmounted (not onBeforeUnmount) for cleanup.

typescript
// SkyStateProvider.ts — a composable that sets up the provider
import { provide, onUnmounted } from 'vue';
import { getOrCreateStore, type ConfigStore } from '@skystate/core';

export const SKYSTATE_KEY = Symbol('skystate');

export function useSkyStateProvider(options: {
  apiUrl: string;
  accountSlug: string;
  projectSlug: string;
  resolveEnvironment?: () => string;
}) {
  let devKey: string | undefined;
  try {
    devKey = (import.meta as { env?: Record<string, string> }).env?.SKYSTATE_DEV_KEY;
  } catch { /* ignore */ }

  const store = getOrCreateStore({
    ...options,
    resolveEnvironment: options.resolveEnvironment ?? (() => {
      try {
        return (import.meta as { env?: { MODE?: string } }).env?.MODE ?? '';
      } catch {
        return '';
      }
    }),
    clientHeader: '@skystate/vue/0.1.0',
    devKey,
  });

  provide(SKYSTATE_KEY, store);

  onUnmounted(() => store.dispose());

  return store;
}
typescript
// useProjectConfig.ts
import { ref, onUnmounted, type Ref } from 'vue';
import { inject } from 'vue';
import type { ConfigStore } from '@skystate/core';
import { SKYSTATE_KEY } from './SkyStateProvider.js';

export function useProjectConfig<T>(
  path?: string,
  fallback?: T,
): { data: Ref<T | undefined>; isLoading: Ref<boolean>; error: Ref<Error | null> } {
  const store = inject<ConfigStore>(SKYSTATE_KEY);
  if (!store) throw new Error('useProjectConfig must be called within a SkyState provider');

  const resolvedPath = path ?? '';

  const data = ref<T | undefined>(
    (store.getSnapshot(resolvedPath) ?? fallback) as T | undefined,
  ) as Ref<T | undefined>;
  const isLoading = ref(store.isLoading);
  const error = ref<Error | null>(store.error);

  const unsubValue = store.subscribe(resolvedPath, () => {
    const raw = store.getSnapshot(resolvedPath);
    data.value = (raw === undefined ? fallback : raw) as T | undefined;

    // Auto-registration
    if (path && fallback !== undefined && raw === undefined && !store.isLoading) {
      store.registerIfMissing(path, fallback);
    }
  });

  const unsubStatus = store.subscribe('__status', () => {
    isLoading.value = store.isLoading;
    error.value = store.error;
  });

  onUnmounted(() => {
    unsubValue();
    unsubStatus();
  });

  return { data, isLoading, error };
}

What the Wrapper Must NOT Do

These constraints separate the wrapper layer from the core layer. Violating them creates tight coupling and makes the wrapper untestable.

ProhibitedReason
Make HTTP requests directlyThe core layer owns all HTTP. Wrappers only call getOrCreateStore, subscribe, getSnapshot, and registerIfMissing.
Implement a cacheThe core layer owns the cache with structural sharing. A second cache in the wrapper causes stale-data bugs.
Read from or write to localStorage or sessionStoragePersistence is not part of the wire protocol. Config is always fetched from the server.
Expose the raw ConfigStore instance to consuming componentsComponents must use the hook/composable API. Exposing the store bypasses the reactive wiring.
Implement polling or timersThe core's HttpClient owns polling via Cache-Control max-age.
Call dispose() from hook cleanupOnly the provider should call dispose(). Hooks must unsubscribe only.
Accept devKey as a propThe dev key must come from the build environment to prevent accidental exposure in source code.

Testing

Test utilities

For component tests, inject a mock store via the framework's context mechanism:

typescript
// test-utils.ts — React example
import { render } from '@testing-library/react';
import { SkyStateContext } from '../context.js';
import type { ConfigStore } from '@skystate/core';

export function renderWithStore(ui: React.ReactElement, store: Partial<ConfigStore>) {
  return render(
    <SkyStateContext.Provider value={store as ConfigStore}>
      {ui}
    </SkyStateContext.Provider>
  );
}

// Usage in tests
const mockStore: Partial<ConfigStore> = {
  subscribe: (_path, cb) => { cb(); return () => {}; },
  getSnapshot: (path) => path === 'banner.enabled' ? true : undefined,
  isLoading: false,
  error: null,
  lastFetched: new Date(),
  registerIfMissing: () => {},
};

What to test

  • The hook returns fallback when getSnapshot returns undefined.
  • The hook re-renders when the subscribe callback fires.
  • The hook unsubscribes on unmount (no memory leak).
  • The hook calls registerIfMissing when a key is missing after load.
  • The hook does not call registerIfMissing before load completes (isLoading: true).
  • The provider calls dispose() on unmount.
  • The hook throws a descriptive error when called outside the provider.

Checklist

Before publishing a framework wrapper, verify:

  • [ ] Provider creates ConfigStore exactly once (not on re-render).
  • [ ] Provider injects the store via context / DI — not as a module global.
  • [ ] Provider calls store.dispose() on unmount.
  • [ ] devKey is read from the build environment, not from props.
  • [ ] Hooks subscribe to path and '__status' separately.
  • [ ] Hooks unsubscribe on destroy via the returned unsubscribe function.
  • [ ] Hooks return stable references — value reference is unchanged if the value did not change.
  • [ ] data is fallback when getSnapshot(path) returns undefined.
  • [ ] registerIfMissing is called once per missing key, only after initial load.
  • [ ] No HTTP, caching, localStorage, or timer logic in the wrapper layer.
  • [ ] X-SkyState-Client header is set to @skystate/<framework>/<version>.
  • [ ] Test utilities allow injecting a mock store.
  • [ ] SSR safety: getOrCreateStore is called inside the component lifecycle, not at module load time.

Built with VitePress