Behaviors
A behavior is a portable function that encapsulates reactive logic without any UI. It receives a service, returns a factory, and that factory returns an API of signals, computeds, and actions.
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 primitives - 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 })('+') );};The behavior handles state. The component handles rendering. Clean separation.
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(), })), };};Same disclosure behavior, three different use cases. The logic is shared; the semantics differ.
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> );}Same behavior, different framework. The logic stays the same.
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); });});Pure functions, pure tests.