React integration for Braided - Bridge your stateful systems to React without giving up lifecycle control.
React observes your system. React doesn't own it.
Braided is a minimal, type-safe library for declarative system composition with dependency-aware lifecycle management. Braided React provides the bridge to use those systems seamlessly in React applications.
Braided is a minimal (~250 lines), type-safe library for declarative system composition with dependency-aware lifecycle management. It lets you define stateful resources (databases, WebSockets, caches, etc.) with explicit dependencies, and handles starting/stopping them in the correct order.
Think of it as dependency injection + lifecycle management for JavaScript, inspired by Clojure's Integrant.
Modern React apps often need to manage complex, long-lived resources that don't fit neatly into the React component lifecycle:
- WebSockets & Real-time Feeds (Chat, Multiplayer Games)
- Audio/Video Contexts (WebRTC, Music Apps)
- Complex API Clients (Authentication, Retries, Caching)
- Game Loops & Simulations
- Background Tasks (Sync, Polling, Timers)
Managing these inside useEffect often leads to "dependency hell," double-initialization in StrictMode, and race conditions.
Braided React solves this by letting you define your system outside React using Braided, and then bridging it into React as a fully-typed dependency injection layer. Your resources outlive React's mount/unmount cycles and you decide when/how to stop them.
- π Direct Closure Access: System lives in module scope, React observes directly
- π‘οΈ Lifecycle Safety: Resources survive remounts and StrictMode
- π― Type Safety: Fully inferred types from your system config to your hooks
- π§© Observer Pattern: React components observe the system; they don't drive it
- β‘ React Primitives: Integrates with Suspense and ErrorBoundary
- π§ͺ Testing Friendly: Optional Context for dependency injection in tests
- π¦ Minimal: Thin wrapper around React hooks and Context (for testing)
npm install braided-react braided
Requirements:
react >= 18.0.0(peer dependency)braided >= 0.0.4(peer dependency)
Note: You need both libraries. Braided defines your system, Braided React bridges it to React.
// system.ts import { defineResource } from "braided"; import { createSystemManager, createSystemHooks } from "braided-react"; // A simple counter resource const counterResource = defineResource({ start: () => { let count = 0; const listeners = new Set<() => void>(); return { subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, getSnapshot: () => count, increment: () => { count++; listeners.forEach((l) => l()); }, }; }, halt: () => {}, }); // A logger resource that depends on counter const loggerResource = defineResource({ dependencies: ["counter"], start: ({ counter }) => ({ logCount: () => console.log(`Count: ${counter.getSnapshot()}`), }), halt: () => {}, }); // System configuration export const systemConfig = { counter: counterResource, logger: loggerResource, }; // Create manager and hooks ONCE export const manager = createSystemManager(systemConfig); export const { useSystem, useResource, SystemProvider } = createSystemHooks(manager);
// App.tsx import { Suspense } from "react"; import { useResource } from "./system"; function App() { return ( <Suspense fallback={<div>Starting system...</div>}> <Counter /> </Suspense> ); } function Counter() { const counter = useResource("counter"); // Suspends automatically! const count = useSyncExternalStore(counter.subscribe, counter.getSnapshot); return ( <div> <p>Count: {count}</p> <button onClick={() => counter.increment()}>Increment</button> </div> ); }
That's it! The system starts automatically when useResource is first called, and React suspends until it's ready.
Braided React embraces a dimensional model:
- Z-axis (Closure Space): Where your system lives independently
- X-Y Plane (React Tree): Where your components render
- Hooks: Windows between these dimensions
Your system is a module singleton in closure space. React components observe it through hooks. This separation gives you:
- Lifecycle independence - System outlives React mounts/unmounts
- No prop drilling - Direct access from any component
- Testing flexibility - Context override for dependency injection
// system.ts export const manager = createSystemManager(config); export const { useSystem } = createSystemHooks(manager); // App.tsx <Suspense fallback={<Loading />}> <ErrorBoundary FallbackComponent={ErrorScreen}> <App /> </ErrorBoundary> </Suspense>;
- β Minimal boilerplate
- β Automatic loading (Suspense)
- β Automatic errors (ErrorBoundary)
- β Direct closure access (fast)
// App.tsx import { useSystemStatus } from "./system"; function App() { const { isIdle, isLoading, startSystem } = useSystemStatus(); if (isIdle) { return <WelcomeScreen onStart={startSystem} />; } if (isLoading) { return <LoadingScreen />; } return <ChatRoom />; }
Use when you need:
- Welcome screen before startup
- Defer startup until user action
- Custom loading/error UI
// Component.test.tsx import { SystemProvider } from "./system"; // Same hooks as production! import { startSystem } from "braided"; test("component works", async () => { // Start system with mock resources const mockConfig = { ...config, api: mockApiResource, // defineResource with vi.fn() inside }; const { system } = await startSystem(mockConfig); render( <SystemProvider system={system}> <Component /> </SystemProvider> ); // Test... await haltSystem(mockConfig, system); });
Benefits:
- β Real lifecycle (resources start/halt properly)
- β Easy mocking (just define mock resources)
- β Mix and match (swap only what you need)
- β Type-safe (same config shape)
Creates a manager for idempotent system startup.
const manager = createSystemManager(systemConfig); // Methods: manager.getSystem(); // Promise<StartedSystem> - Start or get system manager.destroySystem(); // Promise<void> - Halt and reset manager.getCurrentSystem(); // StartedSystem | null - Sync check manager.getStartupErrors(); // Map<string, Error> | null manager.isStarted(); // boolean manager.config; // TConfig - Exposed for inspection
Creates typed hooks for a system. Always pass the manager.
const { useSystem, useResource, useSystemStatus, SystemProvider } = createSystemHooks(manager);
Returns:
useSystem()- Get entire system (suspends until ready)useResource(id)- Get single resource (suspends until ready)useSystemStatus()- Manual control (doesn't suspend)SystemProvider- Context override for testing
Hook to access the entire started system.
function Component() { const system = useSystem(); // Suspends automatically! // system.counter, system.logger, etc. }
Behavior:
- Checks Context first (if
SystemProviderin tree) - Falls back to manager
- Suspends (throws Promise) while starting
- Throws Error if startup failed
- Returns system once ready
Hook to access a single resource with full type inference.
function Component() { const counter = useResource("counter"); // Fully typed! counter.increment(); }
Hook for manual startup control. Does not suspend.
function Component() { const { isIdle, isLoading, isReady, isError, system, errors, startSystem } = useSystemStatus(); if (isIdle) return <button onClick={startSystem}>Start</button>; if (isLoading) return <div>Loading...</div>; if (isError) return <div>Error: {errors}</div>; return <div>Ready!</div>; }
Context provider for dependency injection (testing).
<SystemProvider system={mockSystem}> <Component /> </SystemProvider>
Important: braided-react is a lifecycle management and dependency injection library, not a state management library.
When you call useResource('counter'), you get the instance of the counter. If properties change, your component will not re-render automatically.
React 18's useSyncExternalStore is perfect for subscribing to external state:
const counterResource = defineResource({ start: () => { let count = 0; const listeners = new Set<() => void>(); return { // For useSyncExternalStore subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); }, getSnapshot: () => count, // Public API increment: () => { count++; listeners.forEach((l) => l()); }, }; }, }); // In component: function Counter() { const counter = useResource("counter"); const count = useSyncExternalStore(counter.subscribe, counter.getSnapshot); return <button onClick={() => counter.increment()}>{count}</button>; }
You can also use Zustand stores as resources:
const chatStoreResource = defineResource({ start: () => create((set) => ({ messages: [], addMessage: (msg) => set((state) => ({ messages: [...state.messages, msg] })), })), halt: () => {}, }); // In component: function Chat() { const useStore = useResource("chatStore"); const messages = useStore((state) => state.messages); return ( <div> {messages.map((m) => ( <div key={m}>{m}</div> ))} </div> ); }
We provide 4 complete examples demonstrating different integration patterns:
1. Basic - useSyncExternalStore β Start Here
Modern React 18 integration using useSyncExternalStore API for automatic reactivity.
cd examples/basic npm install && npm run dev
Best for: Modern React apps, learning the recommended pattern
Zustand stores managed as Braided resources for centralized state management.
cd examples/lazy-start npm install && npm run dev
Best for: Apps with complex state management, multiple coordinated stores
Resources communicating through an event bus for loose coupling.
cd examples/singleton-manager npm install && npm run dev
Best for: Complex systems, event-driven architectures
4. Outliving React π₯
System running even when React is unmounted.
cd examples/outliving-react npm install && npm run dev
Best for: Music players, WebSocket apps, background sync, game engines
See examples/README.md for detailed comparison.
import { SystemProvider } from "./system"; import { startSystem, haltSystem } from "braided"; describe("ChatRoom", () => { test("sends messages", async () => { // Define mock resource const mockTransport = defineResource({ start: () => ({ send: vi.fn(), receive: vi.fn(), }), halt: () => {}, }); // Create test config const testConfig = { ...productionConfig, transport: mockTransport, // Swap just one resource }; // Start system with mock const { system } = await startSystem(testConfig); render( <SystemProvider system={system}> <ChatRoom /> </SystemProvider> ); // Test... fireEvent.click(screen.getByText("Send")); expect(system.transport.send).toHaveBeenCalled(); // Cleanup await haltSystem(testConfig, system); }); });
test("displays count", () => { const mockSystem = { counter: { count: 42, increment: vi.fn() }, } as StartedSystem<typeof config>; render( <SystemProvider system={mockSystem}> <Counter /> </SystemProvider> ); expect(screen.getByText("42")).toBeInTheDocument(); });
LazySystemBridgeremoved - Use<Suspense>+useSystemoruseSystemStatuscreateSystemHooksrequires manager - Pass manager as parameterSystemBridgerenamed toSystemProvider- Clearer purpose
const { SystemBridge, useSystem } = createSystemHooks<typeof config>(); const manager = createSystemManager(config); <LazySystemBridge manager={manager} SystemBridge={SystemBridge}> <App /> </LazySystemBridge>;
const manager = createSystemManager(config); const { useSystem } = createSystemHooks(manager); <Suspense fallback={<Loading />}> <App /> </Suspense>;
const manager = createSystemManager(config); const { useSystemStatus } = createSystemHooks(manager); function App() { const { isIdle, isLoading, startSystem } = useSystemStatus(); if (isIdle) return <WelcomeScreen onStart={startSystem} />; if (isLoading) return <LoadingScreen />; return <ChatRoom />; }
See CHANGELOG.md for detailed migration guide.
Braided React follows the same philosophy as Braided:
- Simple over easy - Minimal API that composes well
- Explicit over implicit - No magic, no scanning, just data
- Data over code - Systems are declared as data structures
- Testable by default - No global state, easy to mock
- Type-safe - Full TypeScript support with inference
- React observes, doesn't own - System lifecycle is independent
React components are observers of your system. They watch for changes and re-render when needed. But they don't control the system's lifecycle. This separation of concerns leads to:
- Simpler components - Just observe and render
- Easier testing - Mock the system, not React
- Better performance - System lives outside React's render cycle
- More flexibility - System can be used outside React
- Braided - The core system composition library
- Braided React - React integration (this library)
ISC