Skip to content

Composing Signals

Rimitive is built on composition. You pick the modules you need and compose() wires them together into a reactive service.

The simplest setup is just signals:

import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
const svc = compose(SignalModule, ComputedModule, EffectModule);

compose() returns a use function with the primitives accessible on it:

const { signal, computed, effect } = svc;

Signals hold reactive values:

const count = signal(0);
count(); // read: 0
count(1); // write: 1
count(); // read: 1

Computeds derive values from signals:

const doubled = computed(() => count() * 2);
doubled(); // 2 (count is 1)
count(5);
doubled(); // 10

Effects run side effects when dependencies change:

effect(() => {
console.log('Count is now:', count());
});
// logs: "Count is now: 5"
count(10);
// logs: "Count is now: 10"

Effects run synchronously — when a signal changes, effects execute immediately, not on the next tick. This makes reasoning about state straightforward: after a write, all effects have already run.


import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
// Compose the modules
const svc = compose(SignalModule, ComputedModule, EffectModule);
const { signal, computed, effect } = svc;
// Create reactive state
const firstName = signal('Ada');
const lastName = signal('Lovelace');
// Derive values
const fullName = computed(() => `${firstName()} ${lastName()}`);
// React to changes
effect(() => {
console.log('Full name:', fullName());
});
// logs: "Full name: Ada Lovelace"
// Update
lastName('Byron');
// logs: "Full name: Ada Byron"

Need batching? Add BatchModule:

import { BatchModule } from '@rimitive/signals/extend';
const svc = compose(SignalModule, ComputedModule, EffectModule, BatchModule);
const { signal, effect, batch } = svc;
const a = signal(1);
const b = signal(2);
effect(() => console.log(a() + b()));
// logs: 3
batch(() => {
a(10);
b(20);
});
// logs: 30 (once, not twice)

Let’s build something real: a todo list with just signals.

import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule } from '@rimitive/signals/extend';
const { signal, computed, effect } = compose(SignalModule, ComputedModule, EffectModule);
// State
const items = signal<{ id: number; text: string; done: boolean }[]>([]);
const filter = signal<'all' | 'active' | 'done'>('all');
// Derived state
const filteredItems = computed(() => {
const f = filter();
if (f === 'all') return items();
return items().filter(item => f === 'done' ? item.done : !item.done);
});
const activeCount = computed(() =>
items().filter(item => !item.done).length
);
// Actions
let nextId = 0;
const addItem = (text: string) => {
items([...items(), { id: nextId++, text, done: false }]);
};
const toggleItem = (id: number) => {
items(items().map(item =>
item.id === id ? { ...item, done: !item.done } : item
));
};
const removeItem = (id: number) => {
items(items().filter(item => item.id !== id));
};
// React to changes
effect(() => {
console.log(`${activeCount()} items left`);
});
// Use it
addItem('Learn Rimitive');
addItem('Build something');
// logs: "2 items left"
toggleItem(0);
// logs: "1 items left"

State, derived values, actions, and reactions — all with just three primitives. This pattern works, but as your app grows you’ll want to extract reusable pieces. That’s where behaviors come in.


You might wonder: why not just export these functions directly?

Composition gives you:

  1. Isolation — Each compose() call creates an independent reactive context
  2. Tree-shaking — Only bundle what you use
  3. Extensibility — Add view modules, router, custom modules later
  4. Testing — Create fresh contexts per test

This is the foundation. Everything else in Rimitive builds on top of compose().