Creating a Behavior
A behavior is a pattern. It’s a function that bundles signals, computeds, and actions into a reusable unit. No UI, no framework—just portable reactive logic.
Important: Behaviors run once. They are not reactive closures — there’s no re-rendering or re-execution at the function level. All reactivity is encapsulated in the primitives (signal, computed, effect). The function creates the reactive graph once; signals handle updates from there.
The Simple Approach
Section titled “The Simple Approach”The easiest way to create a behavior is to import primitives 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, with anything else using the same service:
import { useCounter } from './behaviors/useCounter';
const counter = useCounter(10);counter.increment();counter.count(); // 11This works great for simple cases. The behavior is tied to your service, which is fine when you have a single app context.
The Portable Pattern
Section titled “The Portable Pattern”When you need behaviors that work across different contexts—testing with mocks, sharing between apps, or SSR—use the portable pattern.
The Shape
Section titled “The Shape”// The "portable" pattern is...just wrapping your function in a function that provides the serviceconst behaviorName = (svc: SignalsSvc) => (options?) => { // Create state // Return API};Three levels:
- Service injection —
(svc) =>receives the service and primitives - Factory —
(options?) =>any arguments you want to provide to your function - API — the object consumers interact with
From now on, we’ll be working with portable functions.
A Counter Behavior
Section titled “A Counter Behavior”// You still have to define your service type somewhere!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';
// Instead of destructuring primitives, you provide it a function that expects what your service providesconst useCounter = svc(counter);
const c = useCounter(10);
c.count(); // 10c.increment();c.count(); // 11c.doubled(); // 22c.reset();c.count(); // 10svc(counter) provides (or injects) the service. The caller just provides options.
Why This Shape?
Section titled “Why This Shape?”The two-level function (svc) => (options) => exists for a reason:
Service injection lets behaviors work with any signal implementation. Test with mocks, use in React, use in Rimitive View—same behavior code.
Factory pattern lets you create multiple instances with different options:
const useCounter = svc(counter);
const counterA = useCounter(0);const counterB = useCounter(100);
counterA.count(); // 0counterB.count(); // 100Composing Behaviors
Section titled “Composing Behaviors”Behaviors can compose other behaviors:
const disclosure = ({ signal }: Service) => (initialOpen = false) => { const isOpen = signal(initialOpen);
return { isOpen, open: () => isOpen(true), close: () => isOpen(false), toggle: () => isOpen(!isOpen()), };};
const dropdown = (svc: Service) => { // As a convention, we set these up in the outer function (service layer), although you could // just as easily do the same in the return function, if you don't mind it running on every mount. const useDisclosure = svc(disclosure);
return (options?: { initialOpen?: boolean }) => { const disc = useDisclosure(options?.initialOpen ?? false);
// Add keyboard handling const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') disc.close(); if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); disc.toggle(); } };
return { ...disc, onKeyDown, }; };};dropdown builds on disclosure. Same pattern scales to modals, accordions, tabs—anything with open/close state.
Testing
Section titled “Testing”Behaviors are trivial to test—no DOM, no framework. 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); });});Pure functions, pure tests.