Advanced Store Patterns
Deep dive into advanced store techniques including store-to-store communication, persistence, lifecycle management, and comprehensive API reference.
Store-to-Store Communication
Stores can communicate with each other for complex state management, enabling sophisticated workflows and real-time updates:
// Shopping Cart Store - Central e-commerce state management
wildflower.store('cart', {
state: {
items: [],
discount: 0,
promoCode: '',
shipping: 0,
tax: 0,
taxRate: 0.08
},
computed: {
itemCount() {
return this.items.reduce((sum, item) => sum + item.quantity, 0)
},
subtotal() {
return this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
)
},
// Formatted versions for display
subtotalFormatted() {
return this.subtotal.toFixed(2)
},
taxFormatted() {
return this.tax.toFixed(2)
},
total() {
return Math.max(0, this.subtotal - this.discount + this.shipping + this.tax).toFixed(2)
},
isEmpty() {
return this.items.length === 0
}
},
// Methods (unified paradigm - at top level)
addItem(product) {
const existingItem = this.items.find(item => item.productId === product.id)
if (existingItem) {
existingItem.quantity += 1
} else {
this.items.push({
id: Date.now(),
productId: product.id,
name: product.name,
price: product.price,
quantity: 1,
addedAt: new Date()
})
}
// Notify analytics store
const analyticsStore = wildflower.getStore('analytics')
analyticsStore.trackEvent('item_added', {
productId: product.id,
productName: product.name,
price: product.price,
cartTotal: this.subtotal
})
// Check for promotional discounts
this.updatePromotions()
},
removeItem(itemId) {
const item = this.items.find(item => item.id === itemId)
if (item) {
// Track removal
const analyticsStore = wildflower.getStore('analytics')
analyticsStore.trackEvent('item_removed', {
productId: item.productId,
productName: item.name,
price: item.price,
quantity: item.quantity
})
// Remove from cart
const index = this.items.findIndex(item => item.id === itemId)
this.items.splice(index, 1)
// Recalculate promotions
this.updatePromotions()
}
},
updateQuantity(itemId, quantity) {
const item = this.items.find(item => item.id === itemId)
if (item && quantity > 0) {
item.quantity = quantity
this.updatePromotions()
}
},
applyPromoCode(code) {
this.promoCode = code
this.updatePromotions()
},
updatePromotions() {
const promoStore = wildflower.getStore('promotions')
const discount = promoStore.calculateDiscount(this.items, this.promoCode)
this.discount = discount
// Calculate shipping
this.shipping = this.subtotal >= 50 ? 0 : 5.99
// Calculate tax
this.tax = (this.subtotal - this.discount) * this.taxRate
},
clearCart() {
const analyticsStore = wildflower.getStore('analytics')
analyticsStore.trackEvent('cart_cleared', {
itemCount: this.items.length,
totalValue: this.subtotal
})
this.items = []
this.discount = 0
this.promoCode = ''
this.shipping = 0
this.tax = 0
}
})
// Analytics Store - Track user behavior and generate insights
wildflower.store('analytics', {
state: {
events: [],
sessionId: null,
startTime: null,
pageViews: 0,
conversionFunnel: {
views: 0,
addToCart: 0,
checkout: 0,
purchase: 0
}
},
computed: {
sessionDuration() {
return Math.round((new Date() - this.startTime) / 1000)
},
conversionRate() {
return this.conversionFunnel.views > 0
? Math.round((this.conversionFunnel.addToCart / this.conversionFunnel.views) * 100)
: 0
},
recentEvents() {
return this.events.slice(-10).reverse()
}
},
init() {
this.sessionId = 'session_' + Date.now()
this.startTime = new Date()
this.trackEvent('session_start', {
timestamp: this.startTime,
userAgent: navigator.userAgent
})
},
// Methods (unified paradigm - at top level)
trackEvent(eventName, data) {
const event = {
id: Date.now(),
event: eventName,
data: data,
formattedTime: new Date().toLocaleTimeString(),
sessionId: this.sessionId
}
this.events.push(event)
// Update conversion funnel
this.updateConversionFunnel(eventName, data)
// Send to analytics service (simulated)
this.sendToAnalytics(event)
// Keep events list manageable
if (this.events.length > 100) {
this.events = this.events.slice(-50)
}
},
updateConversionFunnel(eventName, data) {
switch (eventName) {
case 'item_added':
this.conversionFunnel.addToCart++
break
case 'checkout_started':
this.conversionFunnel.checkout++
break
case 'purchase_completed':
this.conversionFunnel.purchase++
break
}
},
trackPageView(page) {
this.pageViews++
this.conversionFunnel.views++
this.trackEvent('page_view', {
page: page,
totalViews: this.pageViews
})
},
async sendToAnalytics(event) {
try {
// Simulate API call
console.log('📊 Analytics Event:', event)
// In a real app, you'd send to your analytics service
// await fetch('/api/analytics', {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(event)
// })
} catch (error) {
console.error('Analytics error:', error)
}
},
clearEvents() {
this.events = []
}
})
// Promotions Store - Handle discounts and special offers
wildflower.store('promotions', {
state: {
availablePromotions: [
{ code: 'SAVE10', discount: 0.10, minAmount: 25, description: '10% off orders over $25' },
{ code: 'WELCOME', discount: 5, minAmount: 0, description: '$5 off first order' },
{ code: 'BULK20', discount: 0.20, minAmount: 100, description: '20% off orders over $100' }
],
activePromotions: [],
seasonalMultiplier: 1.0
},
computed: {
activePromoCodes() {
return this.availablePromotions.map(p => p.code)
}
},
// Methods at top level (Unified Entity Paradigm)
calculateDiscount(cartItems, promoCode) {
const subtotal = cartItems.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
)
let discount = 0
// Apply promo code discount
if (promoCode) {
const promo = this.availablePromotions.find(p => p.code === promoCode)
if (promo && subtotal >= promo.minAmount) {
if (promo.discount < 1) {
// Percentage discount
discount += subtotal * promo.discount
} else {
// Fixed amount discount
discount += promo.discount
}
}
}
// Apply automatic quantity discounts
const itemCount = cartItems.reduce((sum, item) => sum + item.quantity, 0)
if (itemCount >= 5) {
discount += subtotal * 0.05 // 5% bulk discount
}
// Apply seasonal multiplier
discount *= this.seasonalMultiplier
// Track promotion usage
if (discount > 0) {
const analyticsStore = wildflower.getStore('analytics')
analyticsStore.trackEvent('promotion_applied', {
promoCode: promoCode,
discount: discount,
subtotal: subtotal,
itemCount: itemCount
})
}
return Math.round(discount * 100) / 100
},
validatePromoCode(code) {
const promo = this.availablePromotions.find(p => p.code === code)
return promo ? promo : null
},
addPromotion(promotion) {
this.availablePromotions.push(promotion)
},
setSeasonalMultiplier(multiplier) {
this.seasonalMultiplier = multiplier
// Notify about seasonal changes
const analyticsStore = wildflower.getStore('analytics')
analyticsStore.trackEvent('seasonal_promotion_updated', {
multiplier: multiplier
})
}
})
<div data-component="store-communication-demo">
<p class="text-muted">Demonstrates how stores communicate and share data in real-time.</p>
<div class="row">
<div class="col-md-6">
<!-- Shopping Cart -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between">
<h6 class="mb-0">Shopping Cart</h6>
<span class="badge bg-primary" data-bind="$cart.itemCount">0</span>
</div>
<div class="card-body">
<!-- Product Selection -->
<div class="mb-3">
<h6>Add Products:</h6>
<div class="d-grid gap-2">
<button data-action="addWidget" class="btn btn-primary btn-sm">
Add Widget ($19.99)
</button>
<button data-action="addGadget" class="btn btn-primary btn-sm">
Add Gadget ($29.99)
</button>
<button data-action="addTool" class="btn btn-primary btn-sm">
Add Tool ($39.99)
</button>
</div>
</div>
<!-- Cart Items -->
<div data-show="!$cart.isEmpty">
<div class="mb-3">
<h6>Cart Items:</h6>
<div data-list="$cart.items">
<template>
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
<div>
<strong data-bind="name"></strong>
<br>
<small class="text-muted">
$<span data-bind="price"></span> x <span data-bind="quantity"></span>
</small>
</div>
<button data-action="removeFromCart" class="btn btn-sm btn-danger">
Remove
</button>
</div>
</template>
</div>
</div>
<!-- Promo Code -->
<div class="mb-3">
<label class="form-label">Promo Code:</label>
<div class="d-flex gap-2">
<input type="text" data-model="promoCode" class="form-control"
placeholder="Enter code (try SAVE10)">
<button data-action="applyPromo" class="btn btn-secondary">Apply</button>
</div>
<small class="text-muted">Try: SAVE10, WELCOME, BULK20</small>
</div>
<!-- Cart Summary -->
<div class="border-top pt-3">
<div class="d-flex justify-content-between">
<span>Subtotal:</span>
<span>$<span data-bind="$cart.subtotalFormatted"></span></span>
</div>
<div class="d-flex justify-content-between" data-show="$cart.discount > 0">
<span class="text-success">Discount:</span>
<span class="text-success">-$<span data-bind="$cart.discount"></span></span>
</div>
<div class="d-flex justify-content-between">
<span>Shipping:</span>
<span>$<span data-bind="$cart.shipping"></span></span>
</div>
<div class="d-flex justify-content-between">
<span>Tax:</span>
<span>$<span data-bind="$cart.taxFormatted"></span></span>
</div>
<div class="d-flex justify-content-between fw-bold border-top pt-2">
<span>Total:</span>
<span>$<span data-bind="$cart.total"></span></span>
</div>
</div>
<button data-action="clearCart" class="btn btn-danger mt-3 w-100">
Clear Cart
</button>
</div>
<div data-show="$cart.isEmpty" class="text-center text-muted">
Cart is empty
</div>
</div>
</div>
</div>
<div class="col-md-6">
<!-- Analytics Dashboard -->
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0">Analytics Dashboard</h6>
</div>
<div class="card-body">
<div class="row text-center mb-3">
<div class="col-6">
<div class="fw-bold" data-bind="$analytics.sessionDuration">0</div>
<small class="text-muted">Session (seconds)</small>
</div>
<div class="col-6">
<div class="fw-bold" data-bind="$analytics.conversionRate">0</div>
<small class="text-muted">Conversion %</small>
</div>
</div>
<div class="mb-3">
<h6>Conversion Funnel:</h6>
<div class="small">
<div>Views: <span data-bind="$analytics.conversionFunnel.views"></span></div>
<div>Add to Cart: <span data-bind="$analytics.conversionFunnel.addToCart"></span></div>
<div>Checkout: <span data-bind="$analytics.conversionFunnel.checkout"></span></div>
<div>Purchase: <span data-bind="$analytics.conversionFunnel.purchase"></span></div>
</div>
</div>
<div>
<h6>Recent Events:</h6>
<div style="max-height: 200px; overflow-y: auto;">
<div data-list="$analytics.recentEvents">
<template>
<div class="small border-bottom py-1">
<div class="d-flex justify-content-between gap-2">
<span data-bind="event" class="fw-bold"></span>
<span data-bind="formattedTime" class="text-muted text-nowrap"></span>
</div>
</div>
</template>
</div>
</div>
</div>
<button data-action="clearAnalytics" class="btn btn-secondary btn-sm mt-3">
Clear Events
</button>
</div>
</div>
</div>
</div>
</div>
wildflower.component('store-communication-demo', {
state: {
promoCode: ''
},
// Subscribe to stores for this.stores auto-injection
subscribe: {
cart: ['items', 'discount'],
analytics: ['events']
},
init() {
// Initialize page view tracking using this.stores
this.stores.analytics.trackPageView('store-communication-demo')
},
// Product addition actions
addWidget() {
this.stores.cart.addItem({
id: 'widget',
name: 'Widget',
price: 19.99
})
},
addGadget() {
this.stores.cart.addItem({
id: 'gadget',
name: 'Gadget',
price: 29.99
})
},
addTool() {
this.stores.cart.addItem({
id: 'tool',
name: 'Tool',
price: 39.99
})
},
// Cart management actions
removeFromCart(event, element, details) {
const itemId = details.item.id
this.stores.cart.removeItem(itemId)
},
applyPromo() {
this.stores.cart.applyPromoCode(this.promoCode.toUpperCase())
this.promoCode = '' // Clear input
},
clearCart() {
this.stores.cart.clearCart()
},
// Analytics actions
clearAnalytics() {
this.stores.analytics.clearEvents()
}
})
Built-in Persistence (storageKey & autoSave)
WildflowerJS provides built-in localStorage persistence for stores. Simply add storageKey and autoSave options to your store definition:
// Automatic persistence - no manual localStorage code needed!
wildflower.store('app-settings', {
storageKey: 'my-app-settings', // localStorage key
autoSave: true, // Auto-save on ANY state change
state: {
theme: 'light',
fontSize: 'medium',
notifications: true,
recentItems: []
},
setTheme(theme) {
this.theme = theme;
// Automatically saved to localStorage!
},
addRecentItem(item) {
this.recentItems = [...this.recentItems, item].slice(-10);
// Nested changes are also auto-saved!
}
});
// On page reload, state is automatically restored from localStorage
storageKey- The localStorage key where state will be savedautoSave: true- Automatically saves state on ANY change, including nested properties- State is automatically restored from localStorage when the store is created
- Works with all state types: primitives, arrays, nested objects
When to use built-in persistence vs manual:
- Built-in - Simple use cases where you want the entire store state persisted automatically
- Manual - When you need selective persistence, encryption, or complex storage logic (see below)
Manual Store Persistence
For more control, implement custom persistence with lifecycle hooks. This demo saves your name and theme to localStorage. Change them, then refresh the page to see them persist:
// Preferences store: loads from localStorage on init, saves on change
wildflower.store('prefs', {
state: { name: 'Guest', theme: 'light' },
init() {
const saved = localStorage.getItem('prefs');
if (saved) {
try {
const data = JSON.parse(saved);
if (data.name) this.name = data.name;
if (data.theme) this.theme = data.theme;
} catch (e) { /* ignore bad data */ }
}
},
setName(name) {
this.name = name;
this._save();
},
setTheme(theme) {
this.theme = theme;
this._save();
},
_save() {
localStorage.setItem('prefs', JSON.stringify({
name: this.name, theme: this.theme
}));
},
clearAll() {
this.name = 'Guest';
this.theme = 'light';
localStorage.removeItem('prefs');
}
});
Components bind to the store with $prefs.name and $prefs.theme. Changes persist across page reloads. The store's init() restores from localStorage on startup.
Store Lifecycle
A store has no element and nothing to render, so it runs a shorter lifecycle than a component. init() runs once when the store registers, and may be async. beforeDestroy() and then destroy() run when the store is unregistered, which is where timers and connections opened in init() are closed. tick() runs on the shared frame loop, and watch: {} reacts to changes in the store's state.
The component render hooks beforeInit, beforeUpdate and onUpdate have no moment to run in a store, so a store ignores them, and development builds warn (WF-219). The demo below opens connections and timers in init() and closes them in destroy():
// Data Service Store - Advanced lifecycle management with connections and retries
wildflower.store('data-service', {
state: {
connected: false,
connecting: false,
data: null,
error: null,
retryCount: 0,
maxRetries: 5,
connectionHistory: [],
syncStatus: 'idle', // idle, syncing, success, error
connectionQuality: 100, // 0-100 percentage
bandwidth: 'high', // low, medium, high
latency: 0
},
computed: {
connectionStatus() {
if (this.connecting) return 'connecting'
if (this.connected) return 'connected'
if (this.error) return 'error'
return 'disconnected'
},
qualityLevel() {
if (this.connectionQuality >= 80) return 'excellent'
if (this.connectionQuality >= 60) return 'good'
if (this.connectionQuality >= 40) return 'fair'
return 'poor'
},
recentHistory() {
return this.connectionHistory.slice(0, 10)
}
},
// Initialize when store is created
init() {
console.log('🚀 Data Service Store initializing...')
this.initializeConnectionHistory()
this.connect()
this.setupPeriodicSync()
this.setupConnectionMonitoring()
},
// Cleanup when store is destroyed
destroy() {
console.log('🛑 Data Service Store destroying...')
if (this.syncInterval) {
clearInterval(this.syncInterval)
}
if (this.monitorInterval) {
clearInterval(this.monitorInterval)
}
this.disconnect()
this.logConnection('destroyed', 'Store destroyed')
},
// Helper methods
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
},
// Methods at top level (Unified Entity Paradigm)
initializeConnectionHistory() {
this.connectionHistory = []
this.logConnection('initialization', 'Store initialized')
},
async connect() {
if (this.connecting || this.connected) return
try {
this.connecting = true
this.error = null
this.logConnection('connecting', 'Attempting connection...')
// Simulate connection negotiation
await this.delay(Math.random() * 1000 + 500)
// Simulate occasional connection failures
if (Math.random() < 0.15) {
throw new Error('Connection timeout')
}
this.connected = true
this.connecting = false
this.retryCount = 0
this.connectionQuality = Math.round(Math.random() * 30 + 70)
this.bandwidth = ['low', 'medium', 'high'][Math.floor(Math.random() * 3)]
this.latency = Math.round(Math.random() * 100 + 20)
this.logConnection('connected', 'Connection established successfully')
this.loadInitialData()
} catch (error) {
this.connected = false
this.connecting = false
this.error = error.message
this.retryCount++
this.logConnection('error', `Connection failed: ${error.message}`)
// Retry with exponential backoff
if (this.retryCount < this.maxRetries) {
const delay = Math.pow(2, this.retryCount) * 1000
this.logConnection('retry', `Retrying in ${delay}ms (attempt ${this.retryCount + 1})`)
setTimeout(() => this.connect(), delay)
} else {
this.logConnection('failed', 'Max retries exceeded')
}
}
},
async loadInitialData() {
if (!this.connected) return
try {
this.syncStatus = 'syncing'
this.logConnection('sync', 'Loading data...')
// Simulate data loading with variable delay
await this.delay(Math.random() * 1000 + 300)
// Simulate occasional data errors
if (Math.random() < 0.1) {
throw new Error('Data fetch failed')
}
this.data = {
lastSync: new Date(),
items: this.generateSampleData(),
version: Math.floor(Math.random() * 1000),
checksum: this.generateChecksum()
}
this.syncStatus = 'success'
this.logConnection('sync_success', `Data loaded: ${this.data.items.length} items`)
} catch (error) {
this.error = error.message
this.syncStatus = 'error'
this.logConnection('sync_error', `Data load failed: ${error.message}`)
}
},
async refreshData() {
if (!this.connected) {
this.connect()
return
}
await this.loadInitialData()
},
setupPeriodicSync() {
this.syncInterval = setInterval(() => {
if (this.connected && this.syncStatus !== 'syncing') {
this.loadInitialData()
}
}, 15000) // Sync every 15 seconds
},
setupConnectionMonitoring() {
this.monitorInterval = setInterval(() => {
if (this.connected) {
// Simulate connection quality changes
const qualityChange = Math.random() * 20 - 10
this.connectionQuality = Math.round(Math.max(0, Math.min(100,
this.connectionQuality + qualityChange)))
// Simulate latency changes
this.latency = Math.round(Math.max(10,
this.latency + Math.random() * 40 - 20))
// Simulate disconnection on poor quality
if (this.connectionQuality < 20) {
this.disconnect()
}
}
}, 5000) // Monitor every 5 seconds
},
disconnect() {
if (this.connected) {
this.connected = false
this.connecting = false
this.connectionQuality = 0
this.logConnection('disconnected', 'Connection closed')
// Auto-reconnect after a delay
setTimeout(() => {
if (!this.connected && !this.connecting) {
this.connect()
}
}, 3000)
}
},
forceReconnect() {
this.disconnect()
setTimeout(() => this.connect(), 1000)
},
clearHistory() {
this.connectionHistory = []
this.logConnection('history_cleared', 'Connection history cleared')
},
logConnection(type, message) {
const entry = {
id: Date.now(),
type,
message,
timestamp: new Date().toLocaleTimeString(),
quality: this.connectionQuality,
latency: this.latency
}
this.connectionHistory.unshift(entry)
// Keep history manageable
if (this.connectionHistory.length > 50) {
this.connectionHistory = this.connectionHistory.slice(0, 50)
}
},
generateSampleData() {
const items = []
const count = Math.floor(Math.random() * 10) + 5
for (let i = 0; i < count; i++) {
items.push({
id: i + 1,
name: `Item ${i + 1}`,
value: Math.floor(Math.random() * 1000),
type: ['A', 'B', 'C'][Math.floor(Math.random() * 3)]
})
}
return items
},
generateChecksum() {
return Math.random().toString(36).substring(7).toUpperCase()
}
})
// Background Tasks Store - Task scheduling and lifecycle management
wildflower.store('background-tasks', {
state: {
tasks: [],
runningTasks: 0,
maxConcurrentTasks: 3,
totalCompleted: 0,
totalFailed: 0,
scheduler: null,
paused: false
},
computed: {
pendingTasks() {
return this.tasks.filter(t => t.status === 'pending')
},
runningTaskList() {
return this.tasks.filter(t => t.status === 'running')
},
completedTasks() {
return this.tasks.filter(t => t.status === 'completed')
},
failedTasks() {
return this.tasks.filter(t => t.status === 'failed')
},
successRate() {
const total = this.totalCompleted + this.totalFailed
return total > 0 ? Math.round((this.totalCompleted / total) * 100) : 0
}
},
init() {
console.log('⏰ Background Tasks Store initializing...')
this.startScheduler()
this.addSampleTasks()
},
destroy() {
console.log('🛑 Background Tasks Store destroying...')
if (this.scheduler) {
clearInterval(this.scheduler)
}
// Cancel all pending tasks
this.tasks.forEach(task => {
if (task.status === 'pending') {
task.status = 'cancelled'
}
})
},
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
},
// Methods at top level (Unified Entity Paradigm)
addTask(task) {
const newTask = {
id: Date.now() + Math.random(),
name: task.name,
type: task.type || 'generic',
priority: task.priority || 'normal',
status: 'pending',
progress: 0,
duration: task.duration || 3000,
createdAt: new Date(),
startedAt: null,
completedAt: null,
error: null,
result: null
}
this.tasks.push(newTask)
this.sortTasksByPriority()
},
addSampleTasks() {
const sampleTasks = [
{ name: 'Data Backup', type: 'backup', priority: 'high', duration: 5000 },
{ name: 'Cache Cleanup', type: 'maintenance', priority: 'normal', duration: 2000 },
{ name: 'Report Generation', type: 'report', priority: 'low', duration: 8000 },
{ name: 'Log Rotation', type: 'maintenance', priority: 'normal', duration: 1500 },
{ name: 'Security Scan', type: 'security', priority: 'high', duration: 7000 }
]
sampleTasks.forEach(task => this.addTask(task))
},
sortTasksByPriority() {
const priorityOrder = { 'high': 0, 'normal': 1, 'low': 2 }
this.tasks.sort((a, b) => {
if (a.status === 'pending' && b.status !== 'pending') return -1
if (a.status !== 'pending' && b.status === 'pending') return 1
return priorityOrder[a.priority] - priorityOrder[b.priority]
})
},
async runTask(taskId) {
const task = this.tasks.find(t => t.id === taskId)
if (!task || task.status !== 'pending') return
task.status = 'running'
task.startedAt = new Date()
task.progress = 0
this.runningTasks++
try {
// Simulate task execution with progress updates.
// Roll failure ONCE per task (~10%), not per step — a per-step roll
// compounds to a ~65% task failure rate across 10 steps.
const steps = 10
const failAtStep = Math.random() < 0.1 ? Math.floor(Math.random() * steps) : -1
for (let i = 0; i < steps; i++) {
// Pause freezes RUNNING tasks mid-flight, not just the
// scheduler's pickup of pending ones — progress bars hold
// in place until Resume.
while (this.paused) {
await this.delay(150)
}
await this.delay(task.duration / steps)
task.progress = Math.round(((i + 1) / steps) * 100)
if (i === failAtStep) {
throw new Error('Task execution failed')
}
}
task.status = 'completed'
task.completedAt = new Date()
task.result = `Task completed successfully in ${task.duration}ms`
this.totalCompleted++
} catch (error) {
task.status = 'failed'
task.error = error.message
task.completedAt = new Date()
this.totalFailed++
}
this.runningTasks--
},
startScheduler() {
if (this.scheduler) return
this.scheduler = setInterval(() => {
if (this.paused) return
// Find pending tasks that can be started
const pendingTasks = this.tasks.filter(t => t.status === 'pending')
const availableSlots = this.maxConcurrentTasks - this.runningTasks
if (pendingTasks.length > 0 && availableSlots > 0) {
const tasksToStart = pendingTasks.slice(0, availableSlots)
tasksToStart.forEach(task => this.runTask(task.id))
}
}, 1000)
},
pauseScheduler() {
this.paused = true
},
resumeScheduler() {
this.paused = false
},
cancelTask(taskId) {
const task = this.tasks.find(t => t.id === taskId)
if (task && task.status === 'pending') {
task.status = 'cancelled'
task.completedAt = new Date()
}
},
clearCompleted() {
this.tasks = this.tasks.filter(t =>
!['completed', 'failed', 'cancelled'].includes(t.status)
)
},
addRandomTask() {
const types = ['backup', 'maintenance', 'report', 'security', 'sync']
const priorities = ['high', 'normal', 'low']
this.addTask({
name: `Random Task ${Math.floor(Math.random() * 1000)}`,
type: types[Math.floor(Math.random() * types.length)],
priority: priorities[Math.floor(Math.random() * priorities.length)],
duration: Math.floor(Math.random() * 5000) + 1000
})
},
resetStats() {
this.totalCompleted = 0
this.totalFailed = 0
}
})
<div data-component="lifecycle-demo">
<p class="text-muted">Demonstrates advanced store lifecycle management with background tasks and data services.</p>
<div class="row">
<div class="col-md-6">
<!-- Data Service Status -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Data Service</h6>
<span data-bind="$data-service.connectionStatus"
data-bind-class="$data-service.connected ? 'badge bg-success' : $data-service.connecting ? 'badge bg-warning' : 'badge bg-danger'">
disconnected
</span>
</div>
<div class="card-body">
<!-- Connection Info -->
<div class="row mb-3">
<div class="col-4">
<small class="text-muted">Quality</small>
<div class="fw-bold">
<span data-bind="$data-service.connectionQuality">0</span>%
<span data-bind="$data-service.qualityLevel"
data-bind-class="$data-service.qualityLevel === 'excellent' ? 'text-success' : $data-service.qualityLevel === 'good' ? 'text-info' : $data-service.qualityLevel === 'fair' ? 'text-warning' : 'text-danger'">
(poor)
</span>
</div>
</div>
<div class="col-4">
<small class="text-muted">Latency</small>
<div class="fw-bold"><span data-bind="$data-service.latency">0</span>ms</div>
</div>
<div class="col-4">
<small class="text-muted">Bandwidth</small>
<div class="fw-bold text-capitalize" data-bind="$data-service.bandwidth">high</div>
</div>
</div>
<!-- Data Info -->
<div data-show="$data-service.data">
<div class="mb-3">
<h6>Data Status:</h6>
<div class="row">
<div class="col-6">
<small class="text-muted">Items</small>
<div class="fw-bold"><span data-bind="$data-service.data ? $data-service.data.items.length : 0">0</span></div>
</div>
</div>
</div>
</div>
<!-- Error Display -->
<div data-show="$data-service.error" class="alert alert-danger py-2">
<small>Error: <span data-bind="$data-service.error"></span></small>
</div>
<!-- Actions -->
<div class="d-grid gap-2">
<button data-action="refreshData" class="btn btn-primary btn-sm">
Refresh Data
</button>
<button data-action="forceReconnect" class="btn btn-warning btn-sm">
Force Reconnect
</button>
<button data-action="clearServiceHistory" class="btn btn-secondary btn-sm">
Clear History
</button>
</div>
</div>
</div>
<!-- Connection History -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">Connection History</h6>
</div>
<div class="card-body p-0">
<div style="max-height: 200px; overflow-y: auto;">
<div data-list="$data-service.recentHistory">
<template>
<div class="px-3 py-2 border-bottom small">
<div class="d-flex justify-content-between">
<span data-bind="type" class="fw-bold text-capitalize"></span>
<span data-bind="timestamp" class="text-muted"></span>
</div>
<div data-bind="message" class="text-muted"></div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<!-- Background Tasks -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Background Tasks</h6>
<span data-bind="$background-tasks.runningTasks" class="badge bg-info">0</span>
</div>
<div class="card-body">
<!-- Task Stats -->
<div class="row mb-3">
<div class="col-4">
<small class="text-muted">Pending</small>
<div class="fw-bold"><span data-bind="$background-tasks.pendingTasks.length">0</span></div>
</div>
<div class="col-4">
<small class="text-muted">Success Rate</small>
<div class="fw-bold"><span data-bind="$background-tasks.successRate">0</span>%</div>
</div>
<div class="col-4">
<small class="text-muted">Failed</small>
<div class="fw-bold text-danger"><span data-bind="$background-tasks.totalFailed">0</span></div>
</div>
</div>
<!-- Scheduler Controls -->
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<span>Scheduler:</span>
<span data-bind="$background-tasks.paused ? 'PAUSED' : 'RUNNING'"
data-bind-class="$background-tasks.paused ? 'text-warning' : 'text-success'">
RUNNING
</span>
</div>
<button data-action="pauseScheduler" class="btn btn-warning btn-sm me-2">
Pause
</button>
<button data-action="resumeScheduler" class="btn btn-success btn-sm">
Resume
</button>
</div>
<!-- Task Actions -->
<div class="d-grid gap-2">
<button data-action="addRandomTask" class="btn btn-primary btn-sm">
Add Random Task
</button>
<button data-action="clearCompleted" class="btn btn-secondary btn-sm">
Clear Completed
</button>
<button data-action="resetTaskStats" class="btn btn-danger btn-sm">
Reset Stats
</button>
</div>
</div>
</div>
<!-- Running Tasks -->
<div class="card">
<div class="card-header">
<h6 class="mb-0">Active Tasks</h6>
</div>
<div class="card-body p-0">
<div style="max-height: 200px; overflow-y: auto;">
<div data-list="$background-tasks.runningTaskList">
<template>
<div class="px-3 py-2 border-bottom">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong data-bind="name"></strong>
<br>
<small class="text-muted">
<span data-bind="type" class="text-capitalize"></span> •
<span data-bind="priority" class="text-capitalize"></span>
</small>
</div>
<div class="text-end">
<div data-bind="progress" class="fw-bold">0</div>
<small class="text-muted">%</small>
</div>
</div>
<div class="progress mt-2" style="height: 12px;">
<div class="progress-bar bg-primary"
data-bind-style="{ width: progress + '%' }">
</div>
</div>
</div>
</template>
</div>
<div data-show="$background-tasks.runningTaskList.length === 0" class="text-center p-3 text-muted">
No active tasks
</div>
</div>
</div>
</div>
</div>
</div>
</div>
wildflower.component('lifecycle-demo', {
// Data Service actions
refreshData() {
const dataService = wildflower.getStore('data-service')
dataService.refreshData()
},
forceReconnect() {
const dataService = wildflower.getStore('data-service')
dataService.forceReconnect()
},
clearServiceHistory() {
const dataService = wildflower.getStore('data-service')
dataService.clearHistory()
},
// Background Tasks actions
addRandomTask() {
const taskStore = wildflower.getStore('background-tasks')
taskStore.addRandomTask()
},
pauseScheduler() {
const taskStore = wildflower.getStore('background-tasks')
taskStore.pauseScheduler()
},
resumeScheduler() {
const taskStore = wildflower.getStore('background-tasks')
taskStore.resumeScheduler()
},
clearCompleted() {
const taskStore = wildflower.getStore('background-tasks')
taskStore.clearCompleted()
},
resetTaskStats() {
const taskStore = wildflower.getStore('background-tasks')
taskStore.resetStats()
}
})
Headless Simulations: tick() in a Store
Stores can implement tick(dt) to drive per-frame work that happens outside any component. The store owns the simulation state and advances it each animation frame; one or more components subscribe and render. Since the store persists after any view is destroyed, you can switch routes, swap viewports, or render the same simulation in multiple components, and the simulation keeps running.
See the physarum-store demo for a complete example: a physics store with no DOM owns the agent grid and pheromone field, and a separate viewport component subscribes and renders. tick(dt) signature, dt clamping, and ordering rules are documented on the Lifecycle Hooks page.
Pools in a Store
A store can declare pools the same way a component does. Put the pool in a store when several components need the same collection, or when the collection must stay after the component showing it is destroyed. The store's tick(dt) steps the entities, and each component reads the pool and draws it its own way.
wildflower.store('swarm', {
state: { running: true },
pools: { bees: {} },
computed: {
count() { return this.pools.bees.length; }
},
init() {
for (let i = 0; i < 200; i++) {
this.pools.bees.push({ id: i, x: Math.random() * 400, y: Math.random() * 300, vx: 0, vy: 0 });
}
},
tick(dt) {
if (!this.running) return;
const s = dt / 1000;
for (const b of this.pools.bees) {
b.vx += (Math.random() - 0.5) * 40 * s;
b.vy += (Math.random() - 0.5) * 40 * s;
b.x += b.vx * s;
b.y += b.vy * s;
}
}
});
wildflower.component('swarm-view', {
subscribe: ['swarm'],
init() { this._g = this.find('canvas').getContext('2d'); },
tick() {
const g = this._g;
g.clearRect(0, 0, 400, 300);
for (const b of this.stores.swarm.pools.bees) g.fillRect(b.x, b.y, 2, 2);
}
});
<div data-component="swarm-view">
<canvas width="400" height="300"></canvas>
<p>Bees: <span data-bind="$swarm.count"></span></p>
</div>
- The same pool. The pools block takes the same options as a component's (
entity,key,props,onAdd,onRemove,onClear),this.getPool(name, { onAdd, onRemove, onClear })attaches hooks at run time as a component's does, and the pool has the same API:push,get,update,remove,clear,length,items, iteration,filterand the rest. Inside the store it isthis.pools.name; a subscribed component or store reaches it asthis.stores.storeName.pools.name, and any code aswildflower.getStore('storeName').pools.name. - Data only. A store has no DOM, so its pools have no container and no template. The DOM methods answer without one:
getElement()returnsundefined,at(i)isitems[i], andswap()moves nothing (it still returnstruewhen both keys exist, as a drawn pool's does). - A
data-poolcontainer renders its own component's pool only. Pointing one at a store's pool, by its name or as$storeName.name, renders nothing. A development build says so and identifies the store (WF-408 when the component declares pools of its own, WF-409 once the page settles otherwise). To show a store's entities, draw them in a component'stick()(canvas, SVG, or your own elements), or copy what a view needs into a pool that component declares. - Counts bind; entities don't. Pool entities are plain objects, not reactive state. A store computed that reads
this.pools.name.lengthstays current, so$swarm.countbinds as usual. A binding can't follow a single entity's fields; read those in atick(). reset()empties them. A store'sreset()returns it to its definition: state back to itsstate:values and every pool empty, withonClearfiring.init()is not re-run for either.- Not persisted.
storageKeysaves and restores state only. Pool entities are plain objects that nothing tracks, soautoSavecannot see them change; a development build notes it when a store declares both (WF-911). To keep entities across a reload, copy what matters into state and refill the pool from it ininit(). - Plugins too. A plugin's pools block works the same way: data only, reached from its methods, stepped by its
tick(), emptied by itsreset(). - Builds. Store pools need a build that carries pools. In one that doesn't, the store still registers without pool handles, and a development build says why.
Virtual Component Protection
Store components are automatically protected from garbage collection during DOM changes:
isVirtual: true flag that protects them from being destroyed during modal operations, route changes, or other DOM manipulations. This ensures persistent global state.
// Internal store component structure
const storeInstance = {
id: instanceId,
name: storeName,
state: reactiveState,
stateManager: stateManager,
definition: storeDefinition,
context: storeContext,
isVirtual: true // This flag protects from DOM-based cleanup
}
// Framework garbage collection excludes virtual components
if (instance.isVirtual || instance.isPersistent) {
// Don't garbage collect virtual/persistent components
return
}
Store API Quick Reference
| API | Purpose |
|---|---|
wildflower.store(name, config) | Create a global store |
wildflower.getStore(name) | Get store instance |
$storeName.property | Bind to store state in HTML |
subscribe: ['storeName'] | Wait for store + enable this.stores |
subscribe: { store: ['path'] } | Wait + receive onStoreUpdate notifications |
store.subscribe(path, callback) | Programmatic subscription |
watch: { 'store:name.path': fn } | Watch store changes in component |
watch: { path(newValue, oldValue, changedPath) {} } (in the store definition) | React to the store's own state; the same keys as a component's watch |
pools: { name: {} } | Declare a data-only pool in the store |
this.stores.storeName.pools.name | Read a store's pool from a subscribed component |
$ shorthand, onStoreUpdate, and subscribe-wait timing.
Store Initialization and Readiness
Stores with async init() provide readiness APIs. Components using subscribe: automatically wait for stores before their init() runs. See Store API Reference for isReady(), waitForReady(), and timeout configuration.
Store-Backed List Patterns
When rendering lists from store data, you have three options. All three work correctly with full reactivity:
Option 1: $ Shorthand (Recommended)
The most direct approach - bind the list directly to store data:
<!-- Bind directly to store items -->
<div data-list="$cart.items">
<template>
<div class="item">
<span data-bind="name"></span>
<span data-bind="price"></span>
</div>
</template>
</div>
<!-- Bind to a computed store property -->
<div data-list="$inventory.availableItems">
<template>...</template>
</div>
Option 2: Computed Property
Use a computed property that returns store data:
<div data-list="items">
<template>
<div class="item" data-bind="name"></div>
</template>
</div>
wildflower.component('item-list', {
state: {},
computed: {
items() {
return wildflower.getStore('myStore').items
}
}
})
Option 3: Subscribe Pattern
Sync store data to component state using subscriptions:
wildflower.component('synced-list', {
state: {
items: []
},
init() {
const store = wildflower.getStore('myStore')
// Initial sync
this.items = [...store.items]
// Subscribe to changes - callback receives (newValue, oldValue)
store.subscribe('items', (newItems) => {
if (Array.isArray(newItems)) {
this.items = [...newItems]
}
})
}
})
- $entity.path - Simplest for read-only bindings, no extra component code needed
- computed - Clean when you need to derive values from store data
- subscribe - Required for two-way sync when component has editable local state that mirrors store data
$entity.path syntax only provides one-way display binding.
Example: A settings panel inside a list item component that edits column names. When the store updates (from another component), the input field values need to update too.
Best Practices
Do
- Create focused, single-responsibility stores
- Use computed properties for derived data
- Implement store persistence for user settings
- Use store-to-store communication for complex workflows
- Leverage lifecycle hooks for initialization and cleanup
- Trust the framework's virtual component protection
Don't
- Create monolithic stores mixing unrelated data
- Mutate store state directly from components
- Forget to handle async operations properly
- Create circular dependencies between stores
- Store temporary UI state in global stores
- Manually manage store cleanup (framework handles it)
Multi-store architecture with real-time chart updates, computed metrics, and theme switching.