Skip to content

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.


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:

  1. Service injection: (svc) => — receives primitives
  2. Factory: (options?) => — configures the instance
  3. API: { ... } — the reactive interface consumers use

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.


Behaviors can use other behaviors. This is where the pattern shines.

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(),
})),
};
};
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,
})),
};
};
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.


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);
}
},
};
};

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),
};
};

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 behaviors
const 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 provider
const svc = compose(SignalModule, ComputedModule, EffectModule)();
function App() {
return (
<SignalProvider svc={svc}>
<ReactDropdown />
</SignalProvider>
);
}

Same behavior, different framework. The logic stays the same.


Rimitive doesn’t enforce naming conventions, but here are some that work:

// Option 1: Plain names
const 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) => ...

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

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.