Skip to content

Core Concepts

ngStato is built around 6 core concepts: State, Actions, Computed, Selectors, Effects, and Hooks.

State

State is the single source of truth. Define it as plain properties in your createStore() config:

ts
const store = createStore({
  users:     [] as User[],
  loading:   false,
  error:     null as string | null,
  selectedId: null as number | null,
  // ...
})

State is read-only outside of actions — you access it via store.users, store.loading, etc.

WARNING

Never mutate state directly. Always use actions.

ts
// ❌ Wrong — will not trigger subscribers or effects
store.users = []

// ✅ Correct — use an action
await store.clearUsers()

Actions

Actions are the only way to modify state. They receive a state proxy as their first argument. They can be sync or async:

ts
actions: {
  // Sync action
  selectUser(state, id: number) {
    state.selectedId = id
  },

  // Async action
  async loadUsers(state) {
    state.loading = true
    state.error = null
    try {
      state.users = await http.get('/users')
    } catch (e) {
      state.error = (e as Error).message
    } finally {
      state.loading = false
    }
  },

  // Action with multiple parameters
  async updateUser(state, id: number, data: Partial<User>) {
    const updated = await http.patch(`/users/${id}`, data)
    state.users = state.users.map(u => u.id === id ? updated : u)
  }
}

Call actions without the state parameter — it's injected automatically:

ts
await store.selectUser(42)
await store.loadUsers()
await store.updateUser(42, { name: 'Alice' })

How mutations work

The state parameter is a Proxy. When you write state.users = [...], the Proxy intercepts the write, updates the internal state, notifies subscribers, and triggers effects.

Computed

Computed values derive from state and are recalculated on every access. Use them for simple derivations:

ts
computed: {
  total:     (state) => state.users.length,
  hasError:  (state) => state.error !== null,
  admins:    (state) => state.users.filter(u => u.role === 'admin')
}

Access them as simple properties:

ts
console.log(store.total)     // 5
console.log(store.hasError)  // false
console.log(store.admins)    // [{ id: 1, role: 'admin', ... }]

Selectors

Selectors are memoized computed values — they only recalculate when their dependencies actually change. Use them for expensive derivations:

ts
selectors: {
  sortedUsers: (state) => {
    // This sort only runs when state.users changes
    return [...state.users].sort((a, b) => a.name.localeCompare(b.name))
  },
  userCount: (state) => state.users.length
}

Computed vs Selectors

computedselectors
RecalculationEvery accessOnly when deps change
MemoizedNoYes (automatic dep tracking)
Use caseSimple, cheap derivationsExpensive computations

Selectors use a Proxy-based dependency tracking system. On the first read, a tracking Proxy records which state keys the selector function accesses. On subsequent reads, it checks if those keys changed via Object.is — if not, it returns the cached result.

Effects

Effects react to state changes and run side effects. They are dependency-tracked: the effect only re-runs when its dependencies change.

ts
effects: [
  // [deps, runner]
  [
    (state) => state.selectedId,       // dependency
    (selectedId, { state, store }) => { // runner — receives the deps value
      if (selectedId) {
        console.log('Selected user:', selectedId)
      }
    }
  ],

  // Effect with cleanup
  [
    (state) => state.filter,
    (filter) => {
      const timer = setInterval(() => console.log('Filter:', filter), 1000)
      return () => clearInterval(timer)  // cleanup — called before next run
    }
  ],

  // Effect with multiple dependencies
  [
    (state) => [state.page, state.pageSize],
    ([page, pageSize], { store }) => {
      store.loadPage(page, pageSize)
    }
  ]
]

Effects run:

  • Once on init (with force = true)
  • On every state change where the dependency value changed

Hooks

Hooks are lifecycle callbacks that let you observe store behavior:

ts
hooks: {
  // Called once when the store initializes
  onInit: (store) => {
    console.log('Store initialized')
    store.loadUsers()   // load data on startup
  },

  // Called when the store is destroyed
  onDestroy: (store) => {
    console.log('Store destroyed')
  },

  // Called before every action
  onAction: (actionName, args) => {
    console.log(`→ ${actionName}`, args)
  },

  // Called after every successful action
  onActionDone: (actionName, duration) => {
    console.log(`✓ ${actionName} (${duration}ms)`)
  },

  // Called when an action throws
  onError: (error, actionName) => {
    console.error(`✗ ${actionName}:`, error.message)
  },

  // Called when state actually changed (shallow diff)
  onStateChange: (prev, next) => {
    console.log('State changed', { prev, next })
  }
}

Inter-store reactions with on()

React to actions from another store without tight coupling:

ts
import { on } from '@ngstato/core'

// React to a single action
const unsub = on(authStore.logout, (store, event) => {
  console.log('User logged out — clearing data')
})

// React to multiple actions
const unsub2 = on(
  [userStore.deleteUser, userStore.updateUser],
  (store, event) => {
    console.log(`${event.name} completed in ${event.duration}ms`)
    if (event.status === 'error') {
      console.error('Error:', event.error?.message)
    }
  }
)

// Unsubscribe when done
unsub()
unsub2()

The event object contains:

PropertyTypeDescription
namestringAction name
argsunknown[]Arguments passed to the action
status'success' | 'error'Outcome
durationnumberExecution time in ms
error?ErrorError if status is 'error'

Feature composition with mergeFeatures()

Build reusable store features and compose them:

ts
import { createStore, mergeFeatures } from '@ngstato/core'

// Define reusable features
function withLoading() {
  return {
    state:   { loading: false, error: null as string | null },
    actions: {
      setLoading: (s: any, v: boolean) => { s.loading = v },
      setError:   (s: any, e: string | null) => { s.error = e }
    },
    computed: { hasError: (s: any) => s.error !== null }
  }
}

function withPagination() {
  return {
    state:   { page: 1, pageSize: 20 },
    actions: {
      setPage:     (s: any, p: number) => { s.page = p },
      setPageSize: (s: any, ps: number) => { s.pageSize = ps }
    }
  }
}

// Compose features into a store
const store = createStore({
  users: [] as User[],
  ...mergeFeatures(withLoading(), withPagination()),
  actions: {
    async loadUsers(state) { /* ... */ }
  }
})

// All merged state + actions + computed are available
store.loading      // false
store.page         // 1
store.hasError     // false
store.setLoading(true)
store.setPage(2)

Next steps

Released under the MIT License.