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)), };};Composing Behaviors
Section titled “Composing Behaviors”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 };};Working with Arrays
Section titled “Working with Arrays”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)
When You Really Want Nested Updates
Section titled “When You Really Want Nested Updates”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 };};But I Really Want Proxies
Section titled “But I Really Want Proxies”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 };};Anti-patterns
Section titled “Anti-patterns”Don’t Create Monolithic State Objects
Section titled “Don’t Create Monolithic State Objects”Putting everything in one big signal defeats the purpose of composition:
// ❌ WRONG - monolithic state, hard to test and maintainconst appState = signal({ user: { name: '', avatar: '', settings: { theme: 'light', notifications: true } }, cart: { items: [], total: 0 }, ui: { sidebarOpen: false, modal: null },});
// Updates become awkwardappState(s => ({ ...s, user: { ...s.user, settings: { ...s.user.settings, theme: 'dark' } }}));// ✅ CORRECT - composed behaviors with focused responsibilitiesconst 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 composableDon’t Manually Sync Derived State
Section titled “Don’t Manually Sync Derived State”Using signals for values that should be computed causes bugs when you forget to update them:
// ❌ WRONG - manually tracking derived stateconst 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 automaticallyconst 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 updatesconst counter = (svc: SignalsSvc) => () => { const count = svc.signal(0); return { count }; // Anyone can write anything!};
// Elsewhere:counter.count(-999); // No validation, no controlcounter.count('oops'); // Type error, but conceptually wrong// ✅ CORRECT - expose signals with controlled actionsconst 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), };};Don’t Tightly Couple Composed Behaviors
Section titled “Don’t Tightly Couple Composed Behaviors”Behaviors should receive dependencies, not import them directly:
// ❌ WRONG - hard dependency on specific cart implementationimport { 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 parametertype CartLike = { items: Readable<CartItem[]> };
const checkout = (svc: SignalsSvc) => (cart: CartLike) => { return { submit: () => submitOrder(cart.items()), };};
// Can inject any cart implementation, including mocks for testingconst c = cart(svc)();const ch = checkout(svc)(c);