Skip to content

Nested stores let you compose child stores inside a parent store. Each child manages its own slice of state while the parent coordinates communication between them.

import { Store, Nested } from '@hurum/core'
const PurchaseStore = Store({
state: {
id: '',
transaction: Nested(TransactionStore),
items: Nested.array(ItemStore),
currencies: Nested.map(CurrencyStore),
},
})

When you write Nested(ChildStore) in a state definition, you are creating a field descriptor — a marker that tells Hurum “this field holds a child store.” When the parent store is created (via Store.create() or useStore()), Hurum reads these descriptors and automatically creates child store instances. The child’s initial state becomes the default value for that field.

Parent store created
→ Hurum finds Nested descriptors in state
→ Creates child store instances automatically
→ Child state is managed by the child store

Hurum provides three types of nested stores:

TypeUsageState shape
Nested(ChildStore)Single childOne child instance
Nested.array(ChildStore)Dynamic listArray of child instances
Nested.map(ChildStore)Keyed collectionMap of key-to-child instances

When the parent store is created, all nested children are created automatically. When the parent is disposed, all children are disposed with it.

Nested stores communicate through events. There are three directions:

Parent Store
├─ Event forwarding (parent → children): automatic
│ Parent emits event → all children receive it
├─ Event bubbling (children → parent): automatic
│ Child emits event → parent can handle it in .on()
└─ Relay (parent transforms events): explicit via .relay()
Parent event → relay handler → new events → children

Parent to children (event forwarding). When an event is emitted in the parent, it is automatically forwarded to all nested children:

// Parent emits PurchaseEvent.saved
// -> All nested children (transaction, items, currencies) receive it

Children to parent (event bubbling). When a child emits an event, it bubbles up to the parent. The parent can subscribe to child events in .on handlers:

const PurchaseStore = Store({
state: {
items: Nested.array(ItemStore),
totalItems: 0,
},
})
.on(ItemEvent.removed, (state) => ({
...state,
totalItems: state.items.length,
}))

Parent relay (cross-child and parent-to-child). The .relay method transforms parent events into child events:

.relay(PurchaseEvent.saved, (event, state) => [
TransactionEvent.start({ purchaseId: event.purchase.id }),
])

Relay handlers receive the event and the current state, and return an array of events to dispatch. This is how siblings communicate — through the parent’s relay.

The scope property gives direct access to child store instances:

// Single child
PurchaseStore.scope.transaction // TransactionStore instance
// Array children
PurchaseStore.scope.items // ItemStore[] instances
PurchaseStore.scope.items[0] // First item store
// Map children
PurchaseStore.scope.currencies // Map<string, CurrencyStore>
PurchaseStore.scope.currencies.get('USD')

Executors can also access scope through their context:

const [RecalcCommand, RecalcExecutor] = CommandExecutor<
{},
{},
{ items: Nested.array<typeof ItemStore> }
>((command, { scope }) => {
// Delegate to a child store
scope.items[0].send(ItemIntents.recalculate({}))
})

A one-to-one composition. The parent always has exactly one instance of the child:

const OrderStore = Store({
state: {
payment: Nested(PaymentStore),
shipping: Nested(ShippingStore),
},
})

A dynamic list of child instances. Each child manages its own state independently:

const TodoListStore = Store({
state: {
todos: Nested.array(TodoStore),
},
})

When a parent forwards an event to Nested.array children, every child instance receives the same event. Each child must check whether the event is meant for it — this is the “id guard” pattern. Without it, every child would process every event:

// In the child store's .on handler:
.on(TodoEvent.toggled, (state, payload) => {
// Guard: only update if this is the targeted instance
if (state.id !== payload.id) return state
return { ...state, completed: !state.completed }
})

A keyed collection where each key maps to a child store instance:

const DashboardStore = Store({
state: {
widgets: Nested.map(WidgetStore),
},
})
// Access by key
DashboardStore.scope.widgets.get('chart-1')
const CheckoutStore = Store({
state: {
cart: Nested(CartStore),
payment: Nested(PaymentStore),
shipping: Nested(ShippingStore),
},
})
.relay(CartEvent.itemsChanged, (event, state) => [
// When cart updates, recalculate shipping
ShippingEvent.recalculate({ items: event.items }),
])
.relay(PaymentEvent.completed, (event) => [
// When payment succeeds, notify cart
CartEvent.orderConfirmed({ orderId: event.orderId }),
])

Siblings never talk directly. Instead, a child event bubbles to the parent, the parent’s relay transforms it, and the resulting event is forwarded to the target child:

Child A emits event
-> bubbles to Parent
-> Parent relay transforms it
-> new event forwarded to Child B

When creating scoped instances with nested stores:

const store = PurchaseStore.create({
initialState: {
id: 'purchase-1',
// Nested state is deep-merged with child defaults
transaction: { status: 'pending' },
items: [
{ id: 'item-1', name: 'Widget', amount: 100 },
{ id: 'item-2', name: 'Gadget', amount: 200 },
],
},
})

When multiple stores are nested in a root store, the parent’s .computed() can derive values that span children. This is how you coordinate data across aggregates without coupling children to each other:

const AppStore = Store({
state: {
todos: Nested(TodoStore),
modal: Nested(ModalStore),
},
})
.computed({
openTodo: (state) => {
if (!state.modal.openId) return null
return state.todos.items.find((t) => t.id === state.modal.openId) ?? null
},
})

Sending a child’s intent through the parent (root delegation) ensures middleware always runs:

store.send(ModalIntents.open({ id: '123' })) // flows through parent's middleware

This pattern is especially useful for presentation stores (modals, palettes, filters) that need to reference domain state. See Working with Nested Stores for detailed examples.

  • Keep nesting shallow. Two levels (parent with children) is the sweet spot. Three or more levels is a signal to reconsider your store boundaries.
  • Relay depth limit is 5. If an event triggers a relay that triggers another relay, the chain stops at depth 5. You’ll see a dev warning at depth 3+. If you hit this, your store graph is too tangled.
  • Children are created and disposed with the parent. You don’t manage child lifecycle separately. When the parent disposes, all children dispose too.
  • Use relay for cross-child communication. Never try to import one child store into another. The parent is the coordinator.