Stores
Stores are proxy-based fine-grained reactive objects that provide an alternative to using signals for nested state. They eliminate the need to call .value repeatedly and provide fine-grained reactivity at the property level.
Key Characteristics
- Fine-grained: Each property is independently reactive
- Ergonomic: No
.valuecalls needed - Nested: Supports deeply nested objects and arrays
- Immutable updates: Use
update()function for changes - Method binding: Automatically binds methods and getters
Creating Stores
Basic Store
import { createStore } from '@signalis/core';
const store = createStore({
count: 0,
user: {
name: 'Jane',
email: 'jane@example.com',
},
});
// Read values (no .value needed!)
console.log(store.count); // 0
console.log(store.user.name); // 'Jane'API Reference
createStore<T extends object>(v: T | Store<T>): Store<T>
Creates a reactive store from an object or array.
Parameters:
v: An object or array (or existing store)
Returns: A proxied version of the object with fine-grained reactivity
update<T extends object>(base: T, recipe: (draft: T) => void): T
Updates a store using a recipe function.
Parameters:
base: The store to updaterecipe: A function that receives a draft and makes changes
Returns: The updated store (for chaining, though mutation happens in-place)
Important: This is the ONLY way to update store values. Direct assignment will throw an error.
Updating Stores
Stores cannot be mutated directly. Use the update() function:
import { createStore, update } from '@signalis/core';
const store = createStore({
count: 0,
user: { name: 'Jane' },
});
// ❌ This throws an error
store.count = 5; // Error: Can't set properties directly on stores
// ✅ Use update() instead
update(store, (draft) => {
draft.count = 5;
draft.user.name = 'Janet';
});Fine-Grained Reactivity
Each property is independently reactive:
import { createStore, update, createEffect } from '@signalis/core';
const store = createStore({
firstName: 'Jane',
lastName: 'Doe',
age: 30,
});
// This effect only depends on firstName
createEffect(() => {
console.log('First name:', store.firstName);
});
// This only triggers the effect
update(store, (draft) => {
draft.firstName = 'Janet';
});
// This does NOT trigger the effect
update(store, (draft) => {
draft.age = 31;
});Working with Arrays
Stores work great with arrays, both as properties of objects and as direct stores:
Arrays as Store Properties
const store = createStore({
items: ['apple', 'banana', 'cherry'],
});
// Read array properties
console.log(store.items.length); // 3
console.log(store.items[0]); // 'apple'
// Update arrays
update(store.items, (draft) => {
draft.push('date');
draft[0] = 'apricot';
});
// Array methods work reactively
createEffect(() => {
const count = store.items.length;
console.log(`${count} items`);
});Direct Array Stores
You can also create a store directly from an array:
const items = createStore(['apple', 'banana', 'cherry']);
// Read array properties
console.log(items.length); // 3
console.log(items[0]); // 'apple'
// Update the array directly
update(items, (draft) => {
draft.push('date');
draft[0] = 'apricot';
});
// Array methods work reactively
createEffect(() => {
const count = items.length;
console.log(`${count} items`);
});
// Array iteration works
items.forEach((item, index) => {
console.log(`${index}: ${item}`);
});Getters and Methods
Stores automatically bind getters and methods to the proxy:
const todoStore = createStore({
todos: [] as Array<{ id: number; text: string; completed: boolean }>,
// Getter - automatically computed
get todoCount() {
return this.todos.length;
},
get completedCount() {
return this.todos.filter((t) => t.completed).length;
},
// Method - automatically bound
addTodo(text: string) {
update(this.todos, (draft) => {
draft.push({
id: Date.now(),
text,
completed: false,
});
});
},
toggleTodo(id: number) {
update(this.todos, (draft) => {
const todo = draft.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
});
},
});
// Use getters (they're reactive!)
createEffect(() => {
console.log(`${todoStore.completedCount} of ${todoStore.todoCount} complete`);
});
// Call methods
todoStore.addTodo('Learn Signalis');
todoStore.toggleTodo(1);Common Patterns
Encapsulation Pattern
Define methods directly on the store to encapsulate update logic. This keeps your business logic colocated with your data and provides a clean API.
const store = createStore({
count: 0,
// Encapsulate update logic in methods
increment() {
update(this, (draft) => {
draft.count++;
});
},
reset() {
update(this, (draft) => {
draft.count = 0;
});
},
});
// Clean API for consumers
store.increment();
store.reset();When to use: Always prefer methods over external update functions for better encapsulation and reusability.
Computed Properties Pattern
Use getters to create derived reactive values within the store. These automatically track dependencies and only recompute when accessed.
const store = createStore({
items: [] as Array<{ price: number }>,
taxRate: 0.08,
// Computed property that depends on items and taxRate
get total() {
const subtotal = this.items.reduce((sum, item) => sum + item.price, 0);
return subtotal * (1 + this.taxRate);
},
});
// Accessing the getter establishes reactive dependencies
console.log(store.total); // Recomputes when items or taxRate changeWhen to use: For any derived state that depends on other store properties. Getters are lazy and only compute when accessed.
Automatic Fine-Grained Reactivity Pattern
Stores provide automatic fine-grained reactivity regardless of where you start your update. Only the specific properties you change will notify their observers, even when updating from the root.
const store = createStore({
user: {
profile: { name: 'Jane', email: 'jane@example.com' },
settings: { theme: 'light', notifications: true },
},
});
// Effect that only observes profile.name
createEffect(() => {
console.log('Name changed:', store.user.profile.name);
});
// Effect that only observes settings.theme
createEffect(() => {
console.log('Theme changed:', store.user.settings.theme);
});
// Update from the root, but only name observers are notified
update(store, (draft) => {
draft.user.profile.name = 'Janet'; // Only first effect runs
});
// Update nested directly if you prefer - same result
update(store.user.settings, (draft) => {
draft.theme = 'dark'; // Only second effect runs
});Why this works: Fine-grained reactivity is determined by which signals your observers read, not by where you start your update. Each property gets its own signal node, so changing profile.name only notifies observers of that specific property.
When to use: This means you can update from wherever is most convenient (often the root) without worrying about performance. Choose the update scope that makes your code clearest.
Array Method Pattern
Arrays in stores support all standard array methods within update callbacks. Use them naturally for more readable code.
const store = createStore({
todos: [] as Array<{ id: number; text: string }>,
});
update(store.todos, (draft) => {
// Use array methods directly
draft.push({ id: 1, text: 'New todo' });
draft.splice(0, 1); // Remove first item
draft.sort((a, b) => a.id - b.id);
});When to use: Anytime you need to modify arrays. All mutations happen on the draft and are applied atomically.
Partial Store Pattern
Pass sub-properties of a store to functions or components that only need access to part of the state.
const appStore = createStore({
user: { name: 'Jane', email: 'jane@example.com' },
settings: { theme: 'dark' },
});
// Function that only needs settings
function updateTheme(settings: typeof appStore.settings, theme: string) {
update(settings, (draft) => {
draft.theme = theme;
});
}
updateTheme(appStore.settings, 'light');When to use: For better separation of concerns and to prevent functions from accessing more state than they need.
Unwrap Pattern
Use unwrap() to get the raw, non-reactive version of a store when you need to serialize it or use it with APIs that don't work with Proxies.
const store = createStore({
user: { name: 'Jane', preferences: { theme: 'dark' } },
});
// Get raw object for serialization
const raw = unwrap(store);
const json = JSON.stringify(raw);
// Send to server or save to localStorage
localStorage.setItem('store', json);When to use: When passing store data to JSON.stringify, external libraries, or any API that expects plain objects.
Stores vs Signals
When to Use Stores
- Deeply nested state
- Many related properties
- You want fine-grained reactivity
- Ergonomics matter (avoiding
.value)
When to Use Signals
- Simple values
- Top-level state
- When you need the signal object itself
- Interfacing with external libraries
Comparison Example
// With Signals
const state = createSignal({
count: 0,
name: 'Jane',
});
console.log(state.value.count); // Have to use .value
state.value = { ...state.value, count: 1 }; // Immutable update required
// Changing count triggers ALL observers, even if they only use name
// With Stores
const store = createStore({
count: 0,
name: 'Jane',
});
console.log(store.count); // No .value needed
update(store, (draft) => {
draft.count = 1;
}); // Mutable-looking update
// Changing count only triggers observers that read countPerformance
Fine-Grained Updates
Stores only notify observers of the specific properties that changed:
const store = createStore({
a: 1,
b: 2,
c: 3,
});
createEffect(() => {
console.log('A changed:', store.a);
});
createEffect(() => {
console.log('B changed:', store.b);
});
update(store, (draft) => {
draft.a = 10; // Only triggers first effect
});Lazy Wrapping
Properties are only wrapped in signals when first accessed:
const store = createStore({
used: 'value',
unused: 'value',
});
// Only 'used' is wrapped in a signal so far
console.log(store.used);
// Now 'unused' gets wrapped too
console.log(store.unused);Gotchas
Direct Assignment Throws
const store = createStore({ count: 0 });
// ❌ Error!
store.count = 5;
// ✅ Use update
update(store, (draft) => {
draft.count = 5;
});Getters Need this
Getters must use this to access other properties:
const store = createStore({
firstName: 'Jane',
lastName: 'Doe',
// ❌ Won't work - outer scope
get fullName() {
return `${store.firstName} ${store.lastName}`;
},
// ✅ Use this
get fullName() {
return `${this.firstName} ${this.lastName}`;
},
});See Also
- Signals - Alternative for simple state
- Update Function - Detailed update() documentation
- Effects - Reacting to store changes
- Examples - Complete todo list example with stores