Creating a Behavior
A behavior is a pattern for encapsulating reactive state and actions into a reusable function (inspired by SAM and downshift).
The Simple Approach
Section titled “The Simple Approach”The easiest way to create a behavior is to import directly from your service:
import { signal, computed } from '../service';
export const useCounter = (initial = 0) => { const count = signal(initial); const doubled = computed(() => count() * 2);
return { count, doubled, increment: () => count(count() + 1), decrement: () => count(count() - 1), reset: () => count(initial), };};Use it anywhere:
import { useCounter } from './behaviors/useCounter';
const counter = useCounter(10);counter.increment();counter.count(); // 11Using a Service Wrapper for Portability
Section titled “Using a Service Wrapper for Portability”When you need behaviors that work across different contexts—testing with mocks, sharing between apps, or SSR—use the portable pattern. This is, generally speaking, the preferred pattern.
import type { Service } from './service';
const myBehavior = (svc: Service) => { return (options?) => // ...}This pattern decouples behaviors from a specific service instance.
A Counter Example
Section titled “A Counter Example”import type { Service } from './service';
const counter = ({ signal, computed }: Service) => (initial = 0) => { const count = signal(initial); const doubled = computed(() => count() * 2);
return { count, doubled, increment: () => count(count() + 1), decrement: () => count(count() - 1), reset: () => count(initial), };};Using It
Section titled “Using It”import { svc } from './service';
// As mentioned before, `svc` itself is callable, injects the service, and returns the inner componentconst useCounter = svc(counter);const c = useCounter(10);
c.count();c.increment();c.doubled();svc(counter) provides (or injects) the service, and is type-safe. Then you have a ready-to-use behavior or component bound to your service.
Composing Behaviors
Section titled “Composing Behaviors”Behaviors ergonomically compose other behaviors. To demonstrate this, let’s start with a basic portable behavior:
const disclosure = ({ signal }: Service) => (initialOpen = false) => { const isOpen = signal(initialOpen);
return { isOpen, open: () => isOpen(true), close: () => isOpen(false), toggle: () => isOpen(!isOpen()), };};Now, we can compose it:
const dropdown = (svc: Service) => { // Utilize the service closure as a space to compose const useDisclosure = svc(disclosure);
return (options?: { initialOpen?: boolean }) => { const disc = useDisclosure(options?.initialOpen ?? false);
// Add keyboard handling // Look ma, no memoization required! const onKeyDown = (e: KeyboardEvent) => { if (e.key !== 'Enter') return; e.preventDefault(); disc.toggle(); };
return { ...disc, onKeyDown, }; };};Now dropdown composes disclosure. The outer service function is a convenient place to inject services a single time.
Testing
Section titled “Testing”Behaviors are trivial to test. Just import your service:
import { describe, it, expect } from 'vitest';import { svc } from './service';
describe('counter', () => { it('increments', () => { const c = svc(counter)(0);
c.increment(); c.increment();
expect(c.count()).toBe(2); });
it('respects initial value', () => { const c = svc(counter)(50);
expect(c.count()).toBe(50); c.reset(); expect(c.count()).toBe(50); });});