Skip to content

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 easiest way to create a behavior is to import primitives directly from your service:

behaviors/useCounter.ts
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(); // 11

This works great for simple cases. The behavior is tied to your service, which is fine when you have a single app context.


When you need behaviors that work across different contexts—testing with mocks, sharing between apps, or SSR—use the portable pattern.

// The "portable" pattern is...just wrapping your function in a function that provides the service
const behaviorName = (svc: SignalsSvc) => (options?) => {
// Create state
// Return API
};

Three levels:

  1. Service injection(svc) => receives the service and primitives
  2. Factory(options?) => any arguments you want to provide to your function
  3. API — the object consumers interact with

From now on, we’ll be working with portable functions.

// 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),
};
};
import { svc } from './service';
// Instead of destructuring primitives, you provide it a function that expects what your service provides
const useCounter = svc(counter);
const c = useCounter(10);
c.count(); // 10
c.increment();
c.count(); // 11
c.doubled(); // 22
c.reset();
c.count(); // 10

svc(counter) provides (or injects) the service. The caller just provides options.

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(); // 0
counterB.count(); // 100

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.


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.