State Management

Reactive global store for cross-component communication

Quick Start

Nounjs provides a complete state management solution with global stores, watchers, computed properties, and actions.

Basic Example
import { createStore, Component, mount } from '@noundryfx/nounjs'

// Create a global store
const store = createStore({
  state: {
    user: null,
    cart: [],
    theme: 'light'
  }
})

// Component A - Updates the store
class UserPanel extends Component {
  template = `<button @click="login">Login</button>`

  login() {
    store.set('user', { name: 'John', email: 'john@example.com' })
  }
}

// Component B - Reacts to store changes
class Header extends Component {
  user = null

  template = `
    <header>
      <span :if="user">Welcome, { user.name }!</span>
      <span :else>Please login</span>
    </header>
  `

  mounted() {
    // Watch store and sync to local state (auto-updates DOM)
    this.$watchStore(store, 'user', (newUser) => {
      this.user = newUser
    })
  }
}

Creating a Store

Basic Store

import { createStore } from '@noundryfx/nounjs'

const store = createStore({
  state: {
    count: 0,
    todos: [],
    user: null
  }
})

Named Stores

// Create multiple stores
const userStore = createStore('user', {
  state: { profile: null, preferences: {} }
})

const cartStore = createStore('cart', {
  state: { items: [], total: 0 }
})

// Access later by name
import { getStore } from '@noundryfx/nounjs'
const cart = getStore('cart')

Reading & Writing State

Store API
// Get single value
const user = store.get('user')

// Get nested value (dot notation)
const city = store.get('user.address.city')

// Get entire state
const state = store.get()  // or store.getState()

// Set single value
store.set('count', 5)

// Set nested value (creates path if needed)
store.set('user.address.city', 'New York')

// Update multiple values
store.update({
  'user.name': 'John',
  'user.email': 'john@example.com',
  'settings.theme': 'dark'
})

// Batch updates (single notification)
store.batch(() => {
  store.set('loading', true)
  store.set('error', null)
  store.set('data', newData)
})
// Watchers notified once at the end

Watchers

React to state changes in real-time.

// Watch specific path
const unwatch = store.watch('user', (newValue, oldValue, path) => {
  console.log('User changed:', newValue)
})

// Watch nested path
store.watch('user.preferences.theme', (theme) => {
  document.body.className = theme
})

// Watch all changes
store.watch('*', (newValue, oldValue, path) => {
  console.log(`${path} changed to:`, newValue)
})

// Stop watching
unwatch()

Deep Reactivity

Watching a parent path fires on any nested change:

store.watch('user', (user) => console.log('Changed'))

// All of these trigger the watcher:
store.set('user.name', 'Jane')
store.set('user.address.city', 'LA')
store.set('user.address.zip', '90001')

Computed Properties

Cached derived values that update when dependencies change.

const store = createStore({
  state: {
    items: [],
    taxRate: 0.1
  },

  computed: {
    // Cached - only recalculates when dependencies change
    subtotal() {
      return this.get('items').reduce((sum, item) => sum + item.price, 0)
    },
    tax() {
      return this.get('subtotal') * this.get('taxRate')
    },
    total() {
      return this.get('subtotal') + this.get('tax')
    },
    itemCount() {
      return this.get('items').length
    }
  }
})

// Access computed properties like regular state
store.get('total')      // Calculates and caches
store.get('itemCount')  // Returns cached value

Actions

Organized state mutations, including async operations.

const store = createStore({
  state: {
    user: null,
    loading: false,
    error: null
  },

  actions: {
    async login({ set, batch }, credentials) {
      set('loading', true)
      set('error', null)

      try {
        const response = await fetch('/api/login', {
          method: 'POST',
          body: JSON.stringify(credentials)
        })
        const user = await response.json()

        batch(() => {
          set('user', user)
          set('loading', false)
        })
      } catch (err) {
        batch(() => {
          set('error', err.message)
          set('loading', false)
        })
      }
    },

    logout({ set }) {
      set('user', null)
    },

    addToCart({ get, set }, item) {
      const items = [...get('cart.items'), item]
      set('cart.items', items)
    }
  }
})

// Dispatch actions
store.dispatch('login', { email: 'john@example.com', password: '***' })
store.dispatch('logout')
store.dispatch('addToCart', { id: 1, name: 'Product', price: 29.99 })

Component Integration

$watchStore

Watch store from component (auto-cleaned on unmount):

class Dashboard extends Component {
  stats = null

  mounted() {
    this.$watchStore(store, 'analytics.stats', (stats) => {
      this.stats = stats  // Auto-updates DOM
    })
  }
}

$connect

Map store paths to local properties:

class UserProfile extends Component {
  user = null
  theme = 'light'

  mounted() {
    // Map store paths to local properties
    this.$connect(store, {
      user: 'user',
      theme: 'settings.theme'
    })
  }
}

$watch - Local State

class Counter extends Component {
  count = 0

  mounted() {
    // Watch local state property
    this.$watch('count', (newVal, oldVal) => {
      console.log(`Count changed from ${oldVal} to ${newVal}`)
      store.set('counter.value', newVal)  // Sync to store
    })

    // Immediate: call with current value right away
    this.$watch('count', (val) => {
      console.log('Current count:', val)
    }, { immediate: true })
  }

  increment() {
    this.count++  // Triggers watcher and auto-updates DOM
  }
}

Array Reactivity

Arrays remain reactive even after reassignment.

class TodoList extends Component {
  items = ['a', 'b', 'c']

  mounted() {
    // Reassign array - still reactive
    this.items = [...this.items, 'd']

    // Mutations on new array work correctly
    this.items.push('e')   // Triggers update
    this.items.pop()       // Triggers update
    this.items.splice(0,1) // Triggers update
  }
}

// Store array reactivity
store.watch('todos', (todos) => {
  console.log('Todos changed:', todos.length)
})

const todos = store.get('todos')
todos.push({ text: 'New todo' })     // Triggers watcher
todos.splice(0, 1)                    // Triggers watcher
todos[0] = { text: 'Updated' }        // Triggers watcher

Custom remove() Method

Nounjs adds a remove() method to reactive arrays:

// Remove by item reference
this.items.remove(item)

// Equivalent to:
const idx = this.items.indexOf(item)
if (idx > -1) this.items.splice(idx, 1)

Real-World Example: Theme Store

The Nounjs Starter app demonstrates a complete theme management system using global stores and $watchStore.

Theme Switcher in Nounjs Starter

See src/lib/store.js and src/components/themeswitcher.noun for the complete implementation.

Light/Dark Mode localStorage Persistence $watchStore Integration

Theme Store Definition

// src/lib/store.js
export const themeStore = createStore('theme', {
  state: () => ({
    mode: 'light',        // 'light' | 'dark'
    primaryColor: 'blue'
  }),

  actions: {
    init({ set, get }) {
      const saved = storage.local.get('theme')
      if (saved?.mode) set('mode', saved.mode)
      this.apply({ get })
    },

    setMode({ set, get }, mode) {
      set('mode', mode)
      this.apply({ get })
      this.persist({ get })
    },

    apply({ get }) {
      if (get('mode') === 'dark') {
        document.documentElement.classList.add('dark')
      } else {
        document.documentElement.classList.remove('dark')
      }
    },

    persist({ get }) {
      storage.local.set('theme', {
        mode: get('mode'),
        primaryColor: get('primaryColor')
      })
    }
  }
})

themeStore.dispatch('init')

Component with $watchStore

// src/components/themeswitcher.noun
export default {
  currentMode: 'light',

  mounted() {
    const store = this.props?.themeStore
    if (store) {
      // Get initial state
      this.currentMode = store.get('mode')

      // Watch for changes - auto-updates UI
      this.$watchStore(store, 'mode', (newMode) => {
        this.currentMode = newMode
      })
    }
  },

  setMode(mode) {
    this.props?.themeStore?.dispatch('setMode', mode)
  }
}

// Template uses currentMode reactively:
// <button @click="setMode('dark')">
//   { currentMode === 'dark' ? 'Dark' : 'Light' }
// </button>

Key Patterns Demonstrated

  • State factory: state: () => ({}) for fresh state per store instance
  • Actions: Destructured helpers { set, get, batch }
  • Persistence: Using storage.local for localStorage
  • Component binding: $watchStore() for reactive UI updates
  • Loose coupling: Store passed via props for testability

Debugging State

Use the built-in DevTools to inspect and edit store state in real-time.

Native DevTools

Enable DevTools to see all component state, edit values live, and track state change events.

Toggle with Ctrl+Shift+D Learn more

API Reference

Store Methods

MethodDescription
get(path?)Get state value at path
set(path, value)Set state value at path
update(updates)Update multiple values
batch(fn)Batch updates, single notification
watch(path, callback)Watch path for changes
dispatch(action, payload)Dispatch an action
reset(newState)Reset state

Component Methods

MethodDescription
$watch(prop, callback, options?)Watch local state property
$watchStore(store, path, callback)Watch store path
$connect(store, mappings)Map store state to local state

Factory Functions

FunctionDescription
createStore(options)Create new store
createStore(name, options)Create named store
getStore(name?)Get store by name
hasStore(name)Check if store exists
deleteStore(name)Delete a store
clearStores()Clear all stores