Skip to content

Composition Over Stores

Rimitive uses composition instead of stores. Instead of one big state object, compose focused behaviors:

// Instead of this:
const store = createStore({
user: {
profile: { name: '', avatar: '' },
settings: { theme: 'light', notifications: true }
},
cart: {
items: [],
total: 0
}
});
// Do this:
const userProfile = (svc: SignalsSvc) => () => {
const name = svc.signal('');
const avatar = svc.signal('');
return {
name,
avatar,
setName: (n: string) => name(n),
setAvatar: (a: string) => avatar(a),
};
};
const userSettings = (svc: SignalsSvc) => () => {
const theme = svc.signal<'light' | 'dark'>('light');
const notifications = svc.signal(true);
return {
theme,
notifications,
toggleTheme: () => theme(theme() === 'light' ? 'dark' : 'light'),
};
};
const cart = (svc: SignalsSvc) => () => {
const items = svc.signal<CartItem[]>([]);
const total = svc.computed(() =>
items().reduce((sum, item) => sum + item.price * item.qty, 0)
);
return {
items,
total,
addItem: (item: CartItem) => items([...items(), item]),
removeItem: (id: string) => items(items().filter(i => i.id !== id)),
};
};

Behaviors can depend on other behaviors by calling them directly:

const checkout = (svc: SignalsSvc) => () => {
const { computed } = svc;
// Call other behaviors directly with the service
const c = cart(svc)();
const profile = userProfile(svc)();
const canCheckout = computed(() =>
c.items().length > 0 && profile.name() !== ''
);
const submit = async () => {
if (!canCheckout()) return;
await submitOrder({
items: c.items(),
customer: profile.name(),
});
};
return { canCheckout, submit };
};

For reactive arrays, use a signal holding an array:

const todoList = ({ signal }: SignalsSvc) => () => {
const items = signal<Todo[]>([]);
return {
items,
add: (text: string) => {
items([...items(), { id: crypto.randomUUID(), text, done: false }]);
},
toggle: (id: string) => {
items(items().map(t =>
t.id === id ? { ...t, done: !t.done } : t
));
},
remove: (id: string) => {
items(items().filter(t => t.id !== id));
},
};
};

This follows an immutable pattern which is:

  • Explicit (you see the update happening)
  • Debuggable (you can log the before/after)
  • Predictable (no proxy magic)

If you genuinely need to update nested data, create a focused updater:

const nestedSettings = (svc: SignalsSvc) => () => {
const settings = svc.signal({
display: { theme: 'light', fontSize: 14 },
privacy: { shareData: false, analytics: true },
});
// Focused updaters for specific paths
const setTheme = (theme: string) => {
settings({
...settings(),
display: { ...settings().display, theme }
});
};
const setFontSize = (size: number) => {
settings({
...settings(),
display: { ...settings().display, fontSize: size }
});
};
// Or a generic path updater if you really need it
const update = <K extends keyof Settings>(
section: K,
updates: Partial<Settings[K]>
) => {
settings({
...settings(),
[section]: { ...settings()[section], ...updates }
});
};
return { settings, setTheme, setFontSize, update };
};

If you’re convinced you need proxy-based stores, you can build them on top of Rimitive’s primitives. The signals are there—wrap them in proxies if you want.

Or use an external store library and connect it via effects:

import { createStore } from 'some-store-library';
const externalStore = createStore({ count: 0 });
const bridged = (svc: SignalsSvc) => () => {
const count = svc.signal(externalStore.getState().count);
// Sync from external store to signal
externalStore.subscribe((state) => {
count(state.count);
});
return { count };
};

Putting everything in one big signal defeats the purpose of composition:

// ❌ WRONG - monolithic state, hard to test and maintain
const appState = signal({
user: { name: '', avatar: '', settings: { theme: 'light', notifications: true } },
cart: { items: [], total: 0 },
ui: { sidebarOpen: false, modal: null },
});
// Updates become awkward
appState(s => ({
...s,
user: { ...s.user, settings: { ...s.user.settings, theme: 'dark' } }
}));
// ✅ CORRECT - composed behaviors with focused responsibilities
const userSettings = (svc: SignalsSvc) => () => {
const theme = svc.signal<'light' | 'dark'>('light');
return {
theme,
toggleTheme: () => theme(theme() === 'light' ? 'dark' : 'light'),
};
};
const cart = (svc: SignalsSvc) => () => {
const items = svc.signal<CartItem[]>([]);
const total = svc.computed(() => items().reduce((sum, i) => sum + i.price, 0));
return { items, total, /* actions */ };
};
// Each behavior is focused, testable, and composable

Using signals for values that should be computed causes bugs when you forget to update them:

// ❌ WRONG - manually tracking derived state
const cart = (svc: SignalsSvc) => () => {
const items = svc.signal<CartItem[]>([]);
const total = svc.signal(0); // Manually maintained!
const addItem = (item: CartItem) => {
items([...items(), item]);
total(total() + item.price); // Must remember to update
};
const removeItem = (id: string) => {
const item = items().find(i => i.id === id);
items(items().filter(i => i.id !== id));
// Oops! Forgot to update total
};
return { items, total, addItem, removeItem };
};
// ✅ CORRECT - compute derived values automatically
const cart = (svc: SignalsSvc) => () => {
const items = svc.signal<CartItem[]>([]);
// Automatically stays in sync
const total = svc.computed(() =>
items().reduce((sum, item) => sum + item.price, 0)
);
return {
items,
total, // Always correct, can't get out of sync
addItem: (item: CartItem) => items([...items(), item]),
removeItem: (id: string) => items(items().filter(i => i.id !== id)),
};
};

Don’t Expose Raw Signals Without Actions

Section titled “Don’t Expose Raw Signals Without Actions”

Exposing signals directly allows uncontrolled mutations from anywhere:

// ❌ WRONG - raw signal exposure, no controlled updates
const counter = (svc: SignalsSvc) => () => {
const count = svc.signal(0);
return { count }; // Anyone can write anything!
};
// Elsewhere:
counter.count(-999); // No validation, no control
counter.count('oops'); // Type error, but conceptually wrong
// ✅ CORRECT - expose signals with controlled actions
const counter = (svc: SignalsSvc) => () => {
const count = svc.signal(0);
return {
count, // Read access
increment: () => count(count() + 1),
decrement: () => count(Math.max(0, count() - 1)), // Validated
reset: () => count(0),
};
};

Behaviors should receive dependencies, not import them directly:

// ❌ WRONG - hard dependency on specific cart implementation
import { cart } from './cart';
const checkout = (svc: SignalsSvc) => () => {
const c = cart(svc)(); // Always uses this specific cart
return {
submit: () => submitOrder(c.items()),
};
};
// ✅ CORRECT - receive the cart as a parameter
type CartLike = { items: Readable<CartItem[]> };
const checkout = (svc: SignalsSvc) => (cart: CartLike) => {
return {
submit: () => submitOrder(cart.items()),
};
};
// Can inject any cart implementation, including mocks for testing
const c = cart(svc)();
const ch = checkout(svc)(c);