Skip to content

An orchestrator that keeps multiple state sources (server state, client state, local states) neatly synchronized without everything ending up in a global monster store. Ideal for larger apps, where states often exist side by side in an uncoordinated manner today.

License

Notifications You must be signed in to change notification settings

shiftbloom-studio/symphony-state

Repository files navigation

Symphony State

npm CI license

Orchestrate multiple state sources without a monolithic global store.

Symphony State is a lightweight orchestration layer that keeps server caches, UI state, and browser persistence in tempo. It does not replace your existing stores. Instead, it composes them into predictable, dependency-driven flows with atomic transactions, derived sections, and observable reconciliation.


Why Symphony State?

Modern apps blend server state (SWR/TanStack Query), local UI state, URL params, and client caches. Those sources drift, and race conditions appear.

Symphony State focuses on coordination:

  • Orchestration, not ownership: keep each state source independent.
  • Deterministic updates: staged commits resolve dependencies in a single wave.
  • Smart reconciliation: establish precedence between server, cache, and optimistic UI.
  • Observability: inspect which source is driving your UI at any time.
  • Performance: local updates notify only subscribers for touched sections.

Install

npm install @shiftbloom-studio/symphony-state

Quickstart (AtomAdapter + React)

import {
  createConductor,
  defineSection,
  createAtomAdapter
} from "@shiftbloom-studio/symphony-state";
import { SymphonyProvider, useSection } from "@shiftbloom-studio/symphony-state/react";

const auth = defineSection({
  key: "auth",
  source: createAtomAdapter({ userId: null as string | null })
});

const conductor = createConductor({ sections: [auth] });

function AuthPanel() {
  const authSection = useSection<{ userId: string | null }>("auth");
  return (
    <button onClick={() => authSection.set({ userId: "42" })}>
      {authSection.value.userId ?? "Login"}
    </button>
  );
}

export function App() {
  return (
    <SymphonyProvider conductor={conductor}>
      <AuthPanel />
    </SymphonyProvider>
  );
}

The Conductor Pattern (multi-source orchestration)

The Conductor stitches independent sources together. Use the orchestrated adapter to define a "symphony" of sources that resolves into a single view.

import {
  createOrchestratedAdapter,
  defineSection,
  createAtomAdapter,
  createExternalStoreAdapter
} from "@shiftbloom-studio/symphony-state";

const serverCache = createExternalStoreAdapter(serverCacheStore);
const localDraft = createAtomAdapter({ title: "", body: "" });

const postSection = defineSection({
  key: "post",
  source: createOrchestratedAdapter({
    instruments: [
      { id: "server", source: serverCache, priority: 1, role: "server", staleAfterMs: 30_000 },
      { id: "draft", source: localDraft, priority: 2, role: "optimistic" }
    ],
    writeTo: "draft",
    optimistic: true
  })
});

Precedence is decided by priority and updatedAt, with staleness protection. By default, the highest priority, freshest instrument wins. You can override reconciliation with a custom reconcile function.


Smart Reconciliation

When the server says X but the client says Y, Symphony State provides reconciliation hooks:

  • Optimistic updates: immediately update UI, then reconcile once server responds.
  • Eventual consistency: keep local drafts until authoritative data catches up.
  • Custom policies: write your own resolver to merge, prefer, or weight sources.
import type { ReconcileContext } from "@shiftbloom-studio/symphony-state";

const reconcile = <T extends { version: number }>(ctx: ReconcileContext<T>) => {
  const entries = Object.entries(ctx.values).map(([id, value]) => ({ id, value }));
  const winner = entries.sort((a, b) => b.value.version - a.value.version)[0];
  return {
    value: winner.value,
    sourceId: winner.id,
    updatedAt: ctx.meta[winner.id].updatedAt
  };
};

Note: This example assumes all values have a version property. In production code, add runtime checks or type guards to ensure type safety.


Derived Sections

import { defineDerivedSection } from "@shiftbloom-studio/symphony-state";

const pricing = defineDerivedSection({
  key: "pricing",
  inputs: ["cart", "auth"],
  compute: (cart, auth) => ({
    total: cart.items.length * (auth.isPremium ? 0.8 : 1)
  })
});

Derived sections are read-only and recompute only when their inputs change.


Transactions

conductor.transaction(() => {
  conductor.getSection("auth").set({ userId: "42" });
  conductor.getSection("cart").patch({ ownerId: "42" });
}, "login");

All updates are staged, resolved in dependency order, and committed atomically.


Persistence

import { createStorageSink } from "@shiftbloom-studio/symphony-state";

const auth = defineSection({
  key: "auth",
  source: createAtomAdapter({ userId: null }),
  persist: createStorageSink({
    key: "symphony-auth",
    throttleMs: 200
  })
});

DevTools & Observability

Use the built-in devtools panel or access orchestration snapshots programmatically.

import { SymphonyDevTools } from "@shiftbloom-studio/symphony-state/devtools";

<SymphonyDevTools maxTransactions={10} />;
// Keep a reference to your orchestrated adapter when wiring the section:
const postAdapter = createOrchestratedAdapter({
  instruments: [
    { id: "server", source: serverCache, priority: 1, role: "server" },
    { id: "draft", source: localDraft, priority: 2, role: "optimistic" }
  ]
});

const posts = defineSection({
  key: "posts",
  source: postAdapter
});

// Later, inspect the current orchestration snapshot:
const snapshot = postAdapter.getSnapshot();

getSnapshot() reports the active driver plus all instrument values, priorities, and staleness flags so you can see which source is in control.


Next.js integration

App Router

// app/layout.tsx
"use client";
import { SymphonyProvider } from "@shiftbloom-studio/symphony-state/react";
import { conductor } from "./symphony";

export default function RootLayout({ children }) {
  return <SymphonyProvider conductor={conductor}>{children}</SymphonyProvider>;
}

Pages Router

// pages/_app.tsx
import { SymphonyProvider } from "@shiftbloom-studio/symphony-state/react";
import { conductor } from "../symphony";

export default function App({ Component, pageProps }) {
  return (
    <SymphonyProvider conductor={conductor}>
      <Component {...pageProps} />
    </SymphonyProvider>
  );
}

Hydration helper

import { SymphonyScript } from "@shiftbloom-studio/symphony-state/react";

<SymphonyScript state={{ auth: { userId: "42" } }} />;

API Reference

API Description
createConductor(config) Create a conductor instance.
defineSection(def) Define a section backed by a source adapter.
defineDerivedSection(def) Define a derived, read-only section.
createOrchestratedAdapter(config) Orchestrate multiple sources with precedence.
createAtomAdapter(initial) Built-in minimal store.
createExternalStoreAdapter(store) Wrap an external get/set/subscribe store.
createUrlParamsAdapter(options) Sync with URL search params.
createStorageSink(options) Persist section values to storage.
SymphonyProvider React context provider.
useSection(key) React hook for section read/write.
useSelector(key, selector) Selector hook with equality.
createSymphony(config) Typed helper that wires a conductor and hooks.
SymphonyDevTools Optional devtools panel.

Design Principles

  • Orchestration, not monolith: state sources remain independent.
  • Deterministic propagation: dependency-ordered commit waves.
  • Composable adapters: plug in external stores without boilerplate.
  • SSR-safe: no unguarded window usage in core.

License

MIT

About

An orchestrator that keeps multiple state sources (server state, client state, local states) neatly synchronized without everything ending up in a global monster store. Ideal for larger apps, where states often exist side by side in an uncoordinated manner today.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •