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 braidedRequirements:
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 inspectionCreates 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 devBest 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 devBest 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 devBest for: Complex systems, event-driven architectures
4. Outliving React 🔥
System running even when React is unmounted.
cd examples/outliving-react
npm install && npm run devBest 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