Skip to content

Creating a Behavior

A behavior is a pattern for encapsulating reactive state and actions into a reusable function (inspired by SAM and downshift).


The easiest way to create a behavior is to import 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:

import { useCounter } from './behaviors/useCounter';
const counter = useCounter(10);
counter.increment();
counter.count(); // 11

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.

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';
// As mentioned before, `svc` itself is callable, injects the service, and returns the inner component
const 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.


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.


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