Skip to content

Repository files navigation

🧶 Braided React

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.

What is Braided?

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.

Why Braided React?

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.

Features

  • 🔌 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)

Installation

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.

Quick Start

1. Define Your System (once, at module level)

// system.tsimport{defineResource}from"braided";import{createSystemManager,createSystemHooks}from"braided-react";// A simple counter resourceconstcounterResource=defineResource({start: ()=>{letcount=0;constlisteners=newSet<()=>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 counterconstloggerResource=defineResource({dependencies: ["counter"],start: ({ counter })=>({logCount: ()=>console.log(`Count: ${counter.getSnapshot()}`),}),halt: ()=>{},});// System configurationexportconstsystemConfig={counter: counterResource,logger: loggerResource,};// Create manager and hooks ONCEexportconstmanager=createSystemManager(systemConfig);exportconst{ useSystem, useResource, SystemProvider }=createSystemHooks(manager);

2. Use in Your App (automatic with Suspense)

// App.tsximport{Suspense}from"react";import{useResource}from"./system";functionApp(){return(<Suspensefallback={<div>Startingsystem...</div>}><Counter/></Suspense>);}functionCounter(){constcounter=useResource("counter");// Suspends automatically!constcount=useSyncExternalStore(counter.subscribe,counter.getSnapshot);return(<div><p>Count: {count}</p><buttononClick={()=>counter.increment()}>Increment</button></div>);}

That's it! The system starts automatically when useResource is first called, and React suspends until it's ready.

Core Concepts

The Z-Axis: Systems Live in Closure Space

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:

  1. Lifecycle independence - System outlives React mounts/unmounts
  2. No prop drilling - Direct access from any component
  3. Testing flexibility - Context override for dependency injection

Three Usage Modes

Mode 1: Production (Automatic - Recommended)

// system.tsexportconstmanager=createSystemManager(config);exportconst{ useSystem }=createSystemHooks(manager);// App.tsx<Suspensefallback={<Loading/>}><ErrorBoundaryFallbackComponent={ErrorScreen}><App/></ErrorBoundary></Suspense>;
  • ✅ Minimal boilerplate
  • ✅ Automatic loading (Suspense)
  • ✅ Automatic errors (ErrorBoundary)
  • ✅ Direct closure access (fast)

Mode 2: Manual Control

// App.tsximport{useSystemStatus}from"./system";functionApp(){const{ isIdle, isLoading, startSystem }=useSystemStatus();if(isIdle){return<WelcomeScreenonStart={startSystem}/>;}if(isLoading){return<LoadingScreen/>;}return<ChatRoom/>;}

Use when you need:

  • Welcome screen before startup
  • Defer startup until user action
  • Custom loading/error UI

Mode 3: Testing (Context Injection)

// Component.test.tsximport{SystemProvider}from"./system";// Same hooks as production!import{startSystem}from"braided";test("component works",async()=>{// Start system with mock resourcesconstmockConfig={
...config,api: mockApiResource,// defineResource with vi.fn() inside};const{ system }=awaitstartSystem(mockConfig);render(<SystemProvidersystem={system}><Component/></SystemProvider>);// Test...awaithaltSystem(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)

API Reference

createSystemManager(config)

Creates a manager for idempotent system startup.

constmanager=createSystemManager(systemConfig);// Methods:manager.getSystem();// Promise<StartedSystem> - Start or get systemmanager.destroySystem();// Promise<void> - Halt and resetmanager.getCurrentSystem();// StartedSystem | null - Sync checkmanager.getStartupErrors();// Map<string, Error> | nullmanager.isStarted();// booleanmanager.config;// TConfig - Exposed for inspection

createSystemHooks(manager)

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

useSystem()

Hook to access the entire started system.

functionComponent(){constsystem=useSystem();// Suspends automatically!// system.counter, system.logger, etc.}

Behavior:

  • Checks Context first (if SystemProvider in tree)
  • Falls back to manager
  • Suspends (throws Promise) while starting
  • Throws Error if startup failed
  • Returns system once ready

useResource(resourceId)

Hook to access a single resource with full type inference.

functionComponent(){constcounter=useResource("counter");// Fully typed!counter.increment();}

useSystemStatus()

Hook for manual startup control. Does not suspend.

functionComponent(){const{ isIdle, isLoading, isReady, isError, system, errors, startSystem }=useSystemStatus();if(isIdle)return<buttononClick={startSystem}>Start</button>;if(isLoading)return<div>Loading...</div>;if(isError)return<div>Error: {errors}</div>;return<div>Ready!</div>;}

SystemProvider

Context provider for dependency injection (testing).

<SystemProvidersystem={mockSystem}><Component/></SystemProvider>

Reactivity & State Management

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.

Recommended Pattern: useSyncExternalStore

React 18's useSyncExternalStore is perfect for subscribing to external state:

constcounterResource=defineResource({start: ()=>{letcount=0;constlisteners=newSet<()=>void>();return{// For useSyncExternalStoresubscribe: (listener: ()=>void)=>{listeners.add(listener);return()=>listeners.delete(listener);},getSnapshot: ()=>count,// Public APIincrement: ()=>{count++;listeners.forEach((l)=>l());},};},});// In component:functionCounter(){constcounter=useResource("counter");constcount=useSyncExternalStore(counter.subscribe,counter.getSnapshot);return<buttononClick={()=>counter.increment()}>{count}</button>;}

Alternative: Zustand Integration

You can also use Zustand stores as resources:

constchatStoreResource=defineResource({start: ()=>create((set)=>({messages: [],addMessage: (msg)=>set((state)=>({messages: [...state.messages,msg]})),})),halt: ()=>{},});// In component:functionChat(){constuseStore=useResource("chatStore");constmessages=useStore((state)=>state.messages);return(<div>{messages.map((m)=>(<divkey={m}>{m}</div>))}</div>);}

Examples

We provide 4 complete examples demonstrating different integration patterns:

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

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.

Testing

Testing with Mock Resources (Recommended)

import{SystemProvider}from"./system";import{startSystem,haltSystem}from"braided";describe("ChatRoom",()=>{test("sends messages",async()=>{// Define mock resourceconstmockTransport=defineResource({start: ()=>({send: vi.fn(),receive: vi.fn(),}),halt: ()=>{},});// Create test configconsttestConfig={
...productionConfig,transport: mockTransport,// Swap just one resource};// Start system with mockconst{ system }=awaitstartSystem(testConfig);render(<SystemProvidersystem={system}><ChatRoom/></SystemProvider>);// Test...fireEvent.click(screen.getByText("Send"));expect(system.transport.send).toHaveBeenCalled();// CleanupawaithaltSystem(testConfig,system);});});

Testing with Manual Mocks (Fast Unit Tests)

test("displays count",()=>{constmockSystem={counter: {count: 42,increment: vi.fn()},}asStartedSystem<typeofconfig>;render(<SystemProvidersystem={mockSystem}><Counter/></SystemProvider>);expect(screen.getByText("42")).toBeInTheDocument();});

Migration from v0.0.2

Breaking Changes

  1. LazySystemBridge removed - Use <Suspense> + useSystem or useSystemStatus
  2. createSystemHooks requires manager - Pass manager as parameter
  3. SystemBridge renamed to SystemProvider - Clearer purpose

Before (v0.0.2)

const{ SystemBridge, useSystem }=createSystemHooks<typeofconfig>();constmanager=createSystemManager(config);<LazySystemBridgemanager={manager}SystemBridge={SystemBridge}><App/></LazySystemBridge>;

After (v0.1.0) - Automatic

constmanager=createSystemManager(config);const{ useSystem }=createSystemHooks(manager);<Suspensefallback={<Loading/>}><App/></Suspense>;

After (v0.1.0) - Manual

constmanager=createSystemManager(config);const{ useSystemStatus }=createSystemHooks(manager);functionApp(){const{ isIdle, isLoading, startSystem }=useSystemStatus();if(isIdle)return<WelcomeScreenonStart={startSystem}/>;if(isLoading)return<LoadingScreen/>;return<ChatRoom/>;}

See CHANGELOG.md for detailed migration guide.

Philosophy

Braided React follows the same philosophy as Braided:

  1. Simple over easy - Minimal API that composes well
  2. Explicit over implicit - No magic, no scanning, just data
  3. Data over code - Systems are declared as data structures
  4. Testable by default - No global state, easy to mock
  5. Type-safe - Full TypeScript support with inference
  6. React observes, doesn't own - System lifecycle is independent

The Observer Pattern

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

Related Projects

License

ISC

About

No description, website, or topics provided.

Resources

Stars

7 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages