Behaviors
A behavior is a portable function that encapsulates reactive logic without any UI. Think of behaviors as headless components—all the state and logic, none of the markup.
The Shape
Section titled “The Shape”type SignalsSvc = { signal: <T>(initial: T) => Writable<T>; computed: <T>(fn: () => T) => Readable<T>; effect: (fn: () => void | (() => void)) => () => void;};
const behaviorName = (svc: SignalsSvc) => (options?: Options) => { // Create reactive state // Define actions // Return the API return { ... };};Three levels:
- Service injection:
(svc) =>— receives modules - Factory:
(options?) =>— configures the instance - API:
{ ... }— the reactive interface consumers use
A Simple Example: Counter
Section titled “A Simple Example: Counter”const counter = (svc: SignalsSvc) => (initial = 0) => { const { signal, computed } = svc;
const count = signal(initial); const doubled = computed(() => count() * 2);
return { count, doubled, increment: () => count(count() + 1), decrement: () => count(count() - 1), reset: () => count(initial), };};Usage with use():
const App = (svc: Service) => () => { const { el, use, computed } = svc; const { count, increment, decrement } = use(counter)(0);
return el('div')( el('span')(computed(() => `Count: ${count()}`)), el('button').props({ onclick: decrement })('-'), el('button').props({ onclick: increment })('+') );};Composing Behaviors
Section titled “Composing Behaviors”Behaviors can use other behaviors. This is where the pattern shines.
Disclosure (open/close)
Section titled “Disclosure (open/close)”const disclosure = (svc: SignalsSvc) => (initialOpen = false) => { const { signal, computed } = svc; const isOpen = signal(initialOpen);
return { isOpen, open: () => isOpen(true), close: () => isOpen(false), toggle: () => isOpen(!isOpen()), // Accessibility props triggerProps: computed(() => ({ 'aria-expanded': String(isOpen()), })), contentProps: computed(() => ({ hidden: !isOpen(), })), };};Dropdown (disclosure + keyboard)
Section titled “Dropdown (disclosure + keyboard)”const dropdown = (svc: SignalsSvc) => (options?: { initialOpen?: boolean }) => { const disc = disclosure(svc)(options?.initialOpen ?? false);
// Add keyboard handling const onKeyDown = (e: KeyboardEvent) => { switch (e.key) { case 'Escape': disc.close(); break; case 'Enter': case ' ': e.preventDefault(); disc.toggle(); break; } };
return { ...disc, triggerProps: svc.computed(() => ({ ...disc.triggerProps(), onkeydown: onKeyDown, })), };};Modal (disclosure + focus trap + scroll lock)
Section titled “Modal (disclosure + focus trap + scroll lock)”const modal = (svc: SignalsSvc) => (options?: { initialOpen?: boolean }) => { const { signal, effect } = svc;
const disc = disclosure(svc)(options?.initialOpen ?? false); const previousFocus = signal<HTMLElement | null>(null);
// Focus trap and scroll lock as an effect effect(() => { if (disc.isOpen()) { // Save current focus previousFocus(document.activeElement as HTMLElement); // Lock scroll document.body.style.overflow = 'hidden'; } else { // Restore scroll document.body.style.overflow = ''; // Restore focus previousFocus()?.focus(); } });
return { ...disc, // Close on backdrop click backdropProps: svc.computed(() => ({ onclick: disc.close, })), // Prevent close when clicking modal content contentProps: svc.computed(() => ({ ...disc.contentProps(), onclick: (e: Event) => e.stopPropagation(), })), };};Behaviors with Options
Section titled “Behaviors with Options”Use options for configuration that affects behavior:
type PaginationOptions = { totalItems: number; pageSize?: number; initialPage?: number;};
const pagination = (svc: SignalsSvc) => (options: PaginationOptions) => { const { signal, computed } = svc;
const pageSize = options.pageSize ?? 10; const currentPage = signal(options.initialPage ?? 1);
const totalPages = computed(() => Math.ceil(options.totalItems / pageSize) );
const hasNext = computed(() => currentPage() < totalPages()); const hasPrev = computed(() => currentPage() > 1);
return { currentPage, totalPages, hasNext, hasPrev, next: () => hasNext() && currentPage(currentPage() + 1), prev: () => hasPrev() && currentPage(currentPage() - 1), goTo: (page: number) => { if (page >= 1 && page <= totalPages()) { currentPage(page); } }, };};Behaviors with Reactive Options
Section titled “Behaviors with Reactive Options”When options need to be reactive, accept signals:
import type { Readable } from '@rimitive/signals';
type SearchOptions = { query: Readable<string>; debounceMs?: number;};
const search = (svc: SignalsSvc) => (options: SearchOptions) => { const { signal, computed, effect } = svc;
const results = signal<SearchResult[]>([]); const isSearching = signal(false);
let timeoutId: number | undefined;
effect(() => { const q = options.query();
clearTimeout(timeoutId);
if (!q) { results([]); return; }
isSearching(true);
timeoutId = window.setTimeout(async () => { const data = await performSearch(q); results(data); isSearching(false); }, options.debounceMs ?? 300); });
return { results, isSearching, resultCount: computed(() => results().length), };};Using Behaviors in React
Section titled “Using Behaviors in React”Behaviors work in React via @rimitive/react:
import { SignalProvider, createHook, useSubscribe } from '@rimitive/react';import { compose } from '@rimitive/core';import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
// Create hooks from behaviorsconst useCounter = createHook(counter);const useDisclosure = createHook(disclosure);
function ReactDropdown() { const disc = useDisclosure(false);
// Subscribe to signals for React re-renders const isOpen = useSubscribe(disc.isOpen); const triggerProps = useSubscribe(disc.triggerProps); const contentProps = useSubscribe(disc.contentProps);
return ( <div> <button {...triggerProps} onClick={disc.toggle}> Toggle </button> {isOpen && ( <div {...contentProps}> Dropdown content </div> )} </div> );}
// Wrap app with providerconst svc = compose(SignalModule, ComputedModule, EffectModule)();
function App() { return ( <SignalProvider svc={svc}> <ReactDropdown /> </SignalProvider> );}Naming Conventions
Section titled “Naming Conventions”Rimitive doesn’t enforce naming conventions, but here are some that work:
// Option 1: Plain namesconst counter = (svc) => ...const disclosure = (svc) => ...
// Option 2: "use" prefix (familiar to React users)const useCounter = (svc) => ...const useDisclosure = (svc) => ...
// Option 3: "create" prefix (emphasizes factory nature)const createCounter = (svc) => ...const createDisclosure = (svc) => ...When to Use Behaviors
Section titled “When to Use Behaviors”Good candidates:
- State that multiple components share (disclosure, selection, pagination)
- Complex state logic (forms, wizards, data fetching)
- Reusable interaction patterns (drag-and-drop, keyboard navigation)
- Anything you’d put in a custom hook in React
Not necessary for:
- One-off component state (just use signals directly)
- Pure presentation logic (no state to manage)
- Framework-specific integrations
Testing Behaviors
Section titled “Testing Behaviors”Behaviors are trivial to test—no DOM, no framework:
import { describe, it, expect } from 'vitest';import { compose } from '@rimitive/core';import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
describe('counter', () => { const createTestSvc = () => compose(SignalModule, ComputedModule, EffectModule)();
it('increments and decrements', () => { const svc = createTestSvc(); const c = counter(svc)(5);
expect(c.count()).toBe(5);
c.increment(); expect(c.count()).toBe(6);
c.decrement(); c.decrement(); expect(c.count()).toBe(4); });
it('computes doubled', () => { const svc = createTestSvc(); const c = counter(svc)(3);
expect(c.doubled()).toBe(6);
c.increment(); expect(c.doubled()).toBe(8); });});Testing Behaviors with Dependencies
Section titled “Testing Behaviors with Dependencies”When a behavior depends on external services, compose with mock modules:
import { compose, defineModule } from '@rimitive/core';import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
// A storage module your behavior depends onconst StorageModule = defineModule({ name: 'storage', create: () => ({ get: (key: string) => localStorage.getItem(key), set: (key: string, value: string) => localStorage.setItem(key, value), }),});
type StorageImpl = { get: (key: string) => string | null; set: (key: string, value: string) => void };
// Behavior that uses storageconst persistedCounter = (svc: SignalsSvc & { storage: StorageImpl }) => (key: string) => { const { signal, effect, storage } = svc;
const saved = storage.get(key); const count = signal(saved ? parseInt(saved, 10) : 0);
effect(() => storage.set(key, String(count())));
return { count, increment: () => count(count() + 1), };};
// Test with mocked storagedescribe('persistedCounter', () => { it('persists to storage', () => { const stored: Record<string, string> = {};
// Create mock storage module const MockStorageModule = defineModule({ name: 'storage', create: (): StorageImpl => ({ get: (key: string) => stored[key] ?? null, set: (key: string, value: string) => { stored[key] = value; }, }), });
// Compose with mock instead of real storage const testSvc = compose( SignalModule, ComputedModule, EffectModule, MockStorageModule );
const c = persistedCounter(testSvc)('count'); c.increment(); c.increment();
expect(stored['count']).toBe('2'); });});For module dependency chains, use override() to swap nested dependencies:
const DbModule = defineModule({ name: 'db', create: () => ({ query: () => fetchFromDatabase() }),});
const UserModule = defineModule({ name: 'user', dependencies: [DbModule], create: ({ db }) => ({ getUser: (id: string) => db.query() }),});
// Test: swap DbModule for a mock inside UserModuleconst MockDb = defineModule({ name: 'db', create: () => ({ query: () => ({ id: '1', name: 'Test' }) }),});
const testSvc = compose(override(UserModule, { db: MockDb }));expect(testSvc.user.getUser('1').name).toBe('Test');Anti-patterns
Section titled “Anti-patterns”Don’t Mix UI into Behaviors
Section titled “Don’t Mix UI into Behaviors”Behaviors should be headless—state and logic only, no DOM:
// ❌ WRONG - behavior creates DOM elementsconst dropdown = (svc: SignalsSvc) => () => { const isOpen = svc.signal(false);
return { isOpen, toggle: () => isOpen(!isOpen()), // Don't do this! Behaviors shouldn't create elements render: () => el('div')( el('button').props({ onclick: () => isOpen(!isOpen()) })('Toggle'), match(isOpen, (open) => open ? el('div')('Content') : null) ), };};// ✅ CORRECT - behavior returns props, component rendersconst dropdown = (svc: SignalsSvc) => () => { const isOpen = svc.signal(false);
return { isOpen, toggle: () => isOpen(!isOpen()), triggerProps: svc.computed(() => ({ 'aria-expanded': String(isOpen()), })), contentProps: svc.computed(() => ({ hidden: !isOpen(), })), };};
// Component uses the behaviorconst Dropdown = (svc: Service) => () => { const { el, match, use } = svc; const disc = use(dropdown)(); return el('div')( el('button').props({ ...disc.triggerProps(), onclick: disc.toggle })('Toggle'), match(disc.isOpen, (open) => open ? el('div').props(disc.contentProps())('Content') : null) );};Don’t Skip the Service Injection Layer
Section titled “Don’t Skip the Service Injection Layer”The double-function pattern exists for portability. Skipping it couples your behavior to a specific service:
// ❌ WRONG - hardcoded service, not portableimport { svc } from './myService';
const counter = (initial = 0) => { const count = svc.signal(initial); // Tied to this specific service return { count, increment: () => count(count() + 1), };};// ✅ CORRECT - service injected, portableconst counter = (svc: SignalsSvc) => (initial = 0) => { const count = svc.signal(initial); return { count, increment: () => count(count() + 1), };};
// Now works with any compatible serviceconst c1 = counter(productionSvc)(0);const c2 = counter(testSvc)(0); // Easy to test with mocksDon’t Confuse Service Layer vs Factory
Section titled “Don’t Confuse Service Layer vs Factory”The service layer (svc) => runs once when you call svc.use(behavior). The factory (options) => runs each time you create an instance. Put code in the right place:
// ❌ WRONG - composing behaviors in the factory (runs every mount)const dropdown = (svc: SignalsSvc) => (options?: { initialOpen?: boolean }) => { // This creates a new disclosure every time dropdown is instantiated const useDisclosure = svc.use(disclosure); // Should be in service layer! const disc = useDisclosure(options?.initialOpen ?? false);
return { ...disc };};// ✅ CORRECT - compose in service layer, instantiate in factoryconst dropdown = (svc: SignalsSvc) => { // Service layer: runs once, sets up composed behaviors const useDisclosure = svc.use(disclosure);
return (options?: { initialOpen?: boolean }) => { // Factory: runs per instance, creates state const disc = useDisclosure(options?.initialOpen ?? false);
return { ...disc }; };};Don’t Create Side Effects Outside effect()
Section titled “Don’t Create Side Effects Outside effect()”Side effects outside effect() won’t be cleaned up when the behavior is disposed:
// ❌ WRONG - side effect not tracked, never cleaned upconst keyboard = (svc: SignalsSvc) => () => { const pressed = svc.signal<string | null>(null);
// This listener is never removed! window.addEventListener('keydown', (e) => pressed(e.key));
return { pressed };};// ✅ CORRECT - effect handles cleanupconst keyboard = (svc: SignalsSvc) => () => { const pressed = svc.signal<string | null>(null);
svc.effect(() => { const handler = (e: KeyboardEvent) => pressed(e.key); window.addEventListener('keydown', handler);
// Cleanup function - runs when effect re-runs or disposes return () => window.removeEventListener('keydown', handler); });
return { pressed };};