Skip to main content

State Management Philosophy

Stan.js takes a pragmatic approach to state management:
  • Simplicity first - No boilerplate or complex patterns required
  • Type-safe - Full TypeScript support with automatic type inference
  • Flexible - Works with React, vanilla JS, or any framework
  • Performant - Automatic optimization through selective subscriptions

Basic State Updates

Direct Updates

The simplest way to update state is using auto-generated actions:
const { actions } = createStore({
  count: 0,
  user: { name: 'John', age: 30 },
})

// Update primitive values
actions.setCount(5)

// Update objects (replaces entire object)
actions.setUser({ name: 'Jane', age: 25 })

Functional Updates

For updates based on previous state, use the functional form:
// Increment counter
actions.setCount(prev => prev + 1)

// Update object property
actions.setUser(prev => ({ ...prev, age: prev.age + 1 }))
When updating objects or arrays, always create a new reference. Mutating the existing object won’t trigger updates:
// ✗ Wrong - mutation won't trigger updates
const user = getState().user
user.age = 31
actions.setUser(user)

// ✓ Correct - new reference triggers update
actions.setUser(prev => ({ ...prev, age: 31 }))

Batch Updates

When making multiple state changes, use batchUpdates to notify subscribers only once:
const { actions, batchUpdates } = createStore({
  firstName: '',
  lastName: '',
  email: '',
})

// Without batching: triggers 3 re-renders
actions.setFirstName('John')
actions.setLastName('Doe')
actions.setEmail('[email protected]')

// With batching: triggers 1 re-render
batchUpdates(() => {
  actions.setFirstName('John')
  actions.setLastName('Doe')
  actions.setEmail('[email protected]')
})

How Batching Works

From src/vanilla/createStore.ts:71-82, batching works by collecting update notifications:
const batchUpdates = (callback: VoidFunction) => {
  try {
    batchedKeys.clear()
    isBatching = true
    callback()
  } finally {
    batchedKeys.forEach(key => {
      listeners[key]?.forEach(listener => listener(state[key as TKey]))
    })
    isBatching = false
  }
}
Custom actions automatically wrap their execution in batchUpdates, so you don’t need to manually batch when using them.

Custom Actions

For complex state logic, create custom actions:
const store = createStore(
  {
    todos: [] as Array<{ id: number; text: string; done: boolean }>,
    filter: 'all' as 'all' | 'active' | 'completed',
  },
  ({ getState, actions }) => ({
    addTodo: (text: string) => {
      const newTodo = {
        id: Date.now(),
        text,
        done: false,
      }
      actions.setTodos([...getState().todos, newTodo])
    },
    
    toggleTodo: (id: number) => {
      actions.setTodos(
        getState().todos.map(todo =>
          todo.id === id ? { ...todo, done: !todo.done } : todo
        )
      )
    },
    
    clearCompleted: () => {
      actions.setTodos(
        getState().todos.filter(todo => !todo.done)
      )
    },
  })
)

// Use custom actions
const { addTodo, toggleTodo, clearCompleted } = store.actions

addTodo('Learn Stan.js')
toggleTodo(1)
clearCompleted()

Custom Action Benefits

Encapsulation

Hide complex state logic behind simple function calls

Reusability

Share logic across components without duplication

Auto-batching

All updates are automatically batched for performance

Type Safety

Full TypeScript support with parameter type checking

Managing Arrays

Adding Items

const { actions, getState } = createStore({
  items: [] as Array<string>,
})

// Add single item
actions.setItems([...getState().items, 'new item'])

// Add multiple items
actions.setItems([...getState().items, 'item1', 'item2'])

// Add at beginning
actions.setItems(['first', ...getState().items])

Removing Items

// Remove by index
actions.setItems(prev => prev.filter((_, i) => i !== indexToRemove))

// Remove by value
actions.setItems(prev => prev.filter(item => item !== valueToRemove))

// Remove by condition
actions.setItems(prev => prev.filter(item => item.active))

Updating Items

const { actions } = createStore({
  users: [] as Array<{ id: number; name: string; active: boolean }>,
})

// Update single item
actions.setUsers(prev =>
  prev.map(user =>
    user.id === targetId
      ? { ...user, name: 'New Name' }
      : user
  )
)

// Update multiple items
actions.setUsers(prev =>
  prev.map(user =>
    user.active ? { ...user, status: 'online' } : user
  )
)

Managing Objects

Partial Updates

const { actions, getState } = createStore({
  user: {
    name: 'John',
    age: 30,
    email: '[email protected]',
    preferences: {
      theme: 'dark',
      notifications: true,
    },
  },
})

// Update single property
actions.setUser(prev => ({ ...prev, age: 31 }))

// Update nested property
actions.setUser(prev => ({
  ...prev,
  preferences: {
    ...prev.preferences,
    theme: 'light',
  },
}))
For deeply nested objects, consider using a library like immer to simplify updates, or flatten your state structure.

Equality Checking

Stan.js uses shallow equality checking to prevent unnecessary updates and re-renders:
// From src/utils.ts:16-41
export const equal = <T>(a: T, b: T) => {
  if (Object.is(a, b)) {
    return true
  }

  if (a instanceof Date && b instanceof Date) {
    return a.getTime() === b.getTime()
  }

  if (
    typeof a !== 'object'
    || a === null
    || typeof b !== 'object'
    || b === null
  ) {
    return false
  }

  const keysA = Object.keys(a) as Array<keyof T>

  if (keysA.length !== Object.keys(b).length) {
    return false
  }

  return keysA.every(key => 
    Object.is(a[key], b[key]) && 
    Object.prototype.hasOwnProperty.call(b, key)
  )
}

What Gets Checked

  • Primitives: Uses Object.is() for exact equality
  • Dates: Compares timestamps using getTime()
  • Objects: Shallow comparison of all properties
  • Arrays: Treated as objects, compares each index
Deep equality is not performed. Nested object changes must create a new parent reference:
const { actions, getState } = createStore({
  data: { nested: { value: 1 } },
})

// ✗ Won't trigger update (same reference)
const data = getState().data
data.nested.value = 2
actions.setData(data)

// ✓ Triggers update (new reference)
actions.setData(prev => ({
  ...prev,
  nested: { ...prev.nested, value: 2 },
}))

Asynchronous State

Loading States

const { actions, getState } = createStore({
  users: [] as Array<User>,
  isLoading: false,
  error: null as string | null,
})

const fetchUsers = async () => {
  actions.setIsLoading(true)
  actions.setError(null)
  
  try {
    const response = await fetch('/api/users')
    const users = await response.json()
    actions.setUsers(users)
  } catch (error) {
    actions.setError(error.message)
  } finally {
    actions.setIsLoading(false)
  }
}

Using Custom Actions

const store = createStore(
  {
    users: [] as Array<User>,
    isLoading: false,
    error: null as string | null,
  },
  ({ actions }) => ({
    fetchUsers: async () => {
      actions.setIsLoading(true)
      actions.setError(null)
      
      try {
        const response = await fetch('/api/users')
        const users = await response.json()
        actions.setUsers(users)
      } catch (error) {
        actions.setError(error.message)
      } finally {
        actions.setIsLoading(false)
      }
    },
  })
)

// Usage
store.actions.fetchUsers()

State Patterns

Feature-based State

// Good: Organize by feature
const authStore = createStore({
  user: null as User | null,
  token: '',
  isAuthenticated: false,
})

const todoStore = createStore({
  todos: [] as Array<Todo>,
  filter: 'all',
})

Normalized State

For complex relational data:
const store = createStore({
  users: {} as Record<number, User>,
  posts: {} as Record<number, Post>,
  comments: {} as Record<number, Comment>,
})

// Add data
actions.setUsers(prev => ({
  ...prev,
  [user.id]: user,
}))

// Access by ID
const user = getState().users[userId]

Derived State Pattern

const store = createStore({
  todos: [] as Array<Todo>,
  filter: 'all' as Filter,
  
  // Derived state using getters
  get filteredTodos() {
    const { todos, filter } = this
    if (filter === 'active') return todos.filter(t => !t.done)
    if (filter === 'completed') return todos.filter(t => t.done)
    return todos
  },
  
  get stats() {
    const total = this.todos.length
    const completed = this.todos.filter(t => t.done).length
    const active = total - completed
    return { total, completed, active }
  },
})

Best Practices

Only store what you can’t derive. Use getters for computed values:
// ✗ Bad - storing derived state
const store = createStore({
  firstName: 'John',
  lastName: 'Doe',
  fullName: 'John Doe', // Redundant!
})

// ✓ Good - derive from source of truth
const store = createStore({
  firstName: 'John',
  lastName: 'Doe',
  get fullName() {
    return `${this.firstName} ${this.lastName}`
  },
})
TypeScript can’t always infer array/object types. Use as to specify:
const store = createStore({
  items: [] as Array<string>, // ✓
  users: [] as User[], // ✓
  data: {} as Record<string, any>, // ✓
})
Use custom actions for operations involving multiple state changes:
const store = createStore(
  { /* state */ },
  ({ actions }) => ({
    complexOperation: () => {
      // Multiple state updates
      // Business logic
      // Side effects
    },
  })
)

Next Steps

Subscriptions

Learn how to react to state changes

Computed Values

Master derived state with getters

Custom Actions

Create reusable action logic

Persistence

Persist state across sessions

Build docs developers (and LLMs) love