Skip to main content

What are Computed Values?

Computed values (also called derived state) are state properties that are calculated from other state values. In Stan.js, you create computed values using JavaScript getters.
Computed values are automatically readonly. Stan.js won’t generate setter actions for properties defined with getters.

Basic Usage

Simple Computed Property

const { useStore } = createStore({
  firstName: 'John',
  lastName: 'Doe',
  
  get fullName() {
    return `${this.firstName} ${this.lastName}`
  },
})

function Component() {
  const { fullName } = useStore()
  // fullName = 'John Doe'
}

Multiple Dependencies

const { useStore } = createStore({
  price: 100,
  quantity: 2,
  taxRate: 0.1,
  
  get subtotal() {
    return this.price * this.quantity
  },
  
  get tax() {
    return this.subtotal * this.taxRate
  },
  
  get total() {
    return this.subtotal + this.tax
  },
})

How Getters Work

Stan.js treats getter properties specially during store initialization:

Dependency Tracking

From src/vanilla/createStore.ts:168-194, Stan.js tracks getter dependencies:
storeKeys.forEach(key => {
  if (Object.getOwnPropertyDescriptor(stateRaw, key)?.get === undefined) {
    return
  }

  const dependencies = new Set<TKey>()
  const proxiedState = new Proxy(state, {
    get: (target, dependencyKey, receiver) => {
      if (!keyInObject(dependencyKey, target)) {
        return undefined
      }

      dependencies.add(dependencyKey)
      return Reflect.get(target, dependencyKey, receiver)
    },
  })

  // Call getter with proxy to track dependencies
  state[key] = Object.getOwnPropertyDescriptor(stateRaw, key)?.get?.call(proxiedState)

  // Subscribe to dependency changes
  subscribe(Array.from(dependencies))(() => {
    const newValue = Object.getOwnPropertyDescriptor(stateRaw, key)?.get?.call(state) as TState[TKey]

    state[key] = newValue
    notifyUpdates(key)
  })
})

The Process

  1. Detection - Stan.js identifies getter properties using Object.getOwnPropertyDescriptor
  2. Tracking - The getter is called with a Proxy to track which state properties it accesses
  3. Subscription - A subscription is created for all accessed dependencies
  4. Updates - When dependencies change, the getter is recalculated and subscribers are notified
Getters are excluded from action generation. From src/vanilla/createStore.ts:21-23, properties with getters are skipped:
if (Object.getOwnPropertyDescriptor(stateRaw, key)?.get !== undefined) {
  return acc
}

Reactive Updates

Computed values automatically update when their dependencies change:
const { useStore, actions } = createStore({
  items: [] as Array<{ price: number; quantity: number }>,
  discountPercent: 0,
  
  get subtotal() {
    return this.items.reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    )
  },
  
  get discount() {
    return this.subtotal * (this.discountPercent / 100)
  },
  
  get total() {
    return this.subtotal - this.discount
  },
})

// When items change, subtotal, discount, and total automatically update
actions.setItems([{ price: 10, quantity: 2 }])

// When discount changes, only discount and total update
actions.setDiscountPercent(10)

Subscription Flow

Common Patterns

Filtered Lists

const { useStore } = createStore({
  todos: [] as Array<{ id: number; text: string; done: boolean }>,
  filter: 'all' as 'all' | 'active' | 'completed',
  
  get filteredTodos() {
    switch (this.filter) {
      case 'active':
        return this.todos.filter(todo => !todo.done)
      case 'completed':
        return this.todos.filter(todo => todo.done)
      default:
        return this.todos
    }
  },
  
  get activeCount() {
    return this.todos.filter(todo => !todo.done).length
  },
  
  get completedCount() {
    return this.todos.filter(todo => todo.done).length
  },
})

Search and Filtering

const { useStore } = createStore({
  products: [] as Array<Product>,
  searchQuery: '',
  category: 'all',
  
  get filteredProducts() {
    let results = this.products
    
    // Filter by category
    if (this.category !== 'all') {
      results = results.filter(p => p.category === this.category)
    }
    
    // Filter by search
    if (this.searchQuery) {
      const query = this.searchQuery.toLowerCase()
      results = results.filter(p => 
        p.name.toLowerCase().includes(query) ||
        p.description.toLowerCase().includes(query)
      )
    }
    
    return results
  },
  
  get resultCount() {
    return this.filteredProducts.length
  },
})

Aggregations

const { useStore } = createStore({
  transactions: [] as Array<{ amount: number; type: 'income' | 'expense' }>,
  
  get totalIncome() {
    return this.transactions
      .filter(t => t.type === 'income')
      .reduce((sum, t) => sum + t.amount, 0)
  },
  
  get totalExpenses() {
    return this.transactions
      .filter(t => t.type === 'expense')
      .reduce((sum, t) => sum + t.amount, 0)
  },
  
  get balance() {
    return this.totalIncome - this.totalExpenses
  },
  
  get averageTransaction() {
    if (this.transactions.length === 0) return 0
    const total = this.transactions.reduce((sum, t) => sum + t.amount, 0)
    return total / this.transactions.length
  },
})

Formatting

const { useStore } = createStore({
  user: {
    firstName: 'john',
    lastName: 'doe',
    email: '[email protected]',
  },
  
  get displayName() {
    const { firstName, lastName } = this.user
    return `${capitalize(firstName)} ${capitalize(lastName)}`
  },
  
  get normalizedEmail() {
    return this.user.email.toLowerCase()
  },
  
  get initials() {
    const { firstName, lastName } = this.user
    return `${firstName[0]}${lastName[0]}`.toUpperCase()
  },
})

Performance Considerations

Computed Values are Cached

Getters are only recalculated when their dependencies change:
const { useStore } = createStore({
  items: [] as Array<number>,
  filter: '',
  
  get expensiveComputation() {
    console.log('Computing...')
    // This only runs when 'items' changes
    return this.items.reduce((sum, n) => sum + n, 0)
  },
})

// This doesn't trigger recomputation
actions.setFilter('new filter')

// This triggers recomputation
actions.setItems([1, 2, 3])

Avoiding Over-Computation

Be careful with expensive operations in getters. They run every time dependencies change:
// ✗ Bad - expensive operation on every render
get sortedItems() {
  return this.items
    .map(item => ({ ...item, score: computeComplexScore(item) }))
    .sort((a, b) => b.score - a.score)
}

// ✓ Better - cache expensive computations
get itemsWithScores() {
  return this.items.map(item => ({
    ...item,
    score: computeComplexScore(item)
  }))
},

get sortedItems() {
  return this.itemsWithScores.sort((a, b) => b.score - a.score)
}

Selective Subscriptions

Components only re-render when the getters they use change:
const { useStore } = createStore({
  todos: [] as Array<Todo>,
  get activeTodos() {
    return this.todos.filter(t => !t.done)
  },
  get completedTodos() {
    return this.todos.filter(t => t.done)
  },
})

function ActiveList() {
  // Only re-renders when activeTodos changes
  const { activeTodos } = useStore()
  return <ul>{activeTodos.map(...)}</ul>
}

function CompletedList() {
  // Only re-renders when completedTodos changes
  const { completedTodos } = useStore()
  return <ul>{completedTodos.map(...)}</ul>
}

Chaining Computed Values

Getters can depend on other getters:
const { useStore } = createStore({
  users: [] as Array<User>,
  searchQuery: '',
  sortBy: 'name' as 'name' | 'age' | 'score',
  
  // First level: filter
  get filteredUsers() {
    if (!this.searchQuery) return this.users
    return this.users.filter(u => 
      u.name.toLowerCase().includes(this.searchQuery.toLowerCase())
    )
  },
  
  // Second level: sort (depends on filteredUsers)
  get sortedUsers() {
    const users = [...this.filteredUsers]
    switch (this.sortBy) {
      case 'name':
        return users.sort((a, b) => a.name.localeCompare(b.name))
      case 'age':
        return users.sort((a, b) => a.age - b.age)
      case 'score':
        return users.sort((a, b) => b.score - a.score)
    }
  },
  
  // Third level: pagination (depends on sortedUsers)
  get paginatedUsers() {
    return this.sortedUsers.slice(0, 10)
  },
})

Type Safety

Computed values maintain full TypeScript type inference:
const { useStore } = createStore({
  count: 0,
  
  get doubled(): number {
    return this.count * 2
  },
  
  get label(): string {
    return `Count is ${this.count}`
  },
})

function Component() {
  const { doubled, label } = useStore()
  // doubled: number
  // label: string
}

Readonly Enforcement

You cannot create actions for computed properties:
const { actions } = createStore({
  firstName: 'John',
  get fullName() {
    return this.firstName
  },
})

// actions.setFirstName exists ✓
// actions.setFullName does NOT exist ✓
This is enforced at the type level:
type Actions<TState extends object> = {
  [K in keyof TState as ActionKey<K>]: (value: TState[K]) => void
}

type RemoveReadonly<T> = Omit<T, GetReadonlyKeys<T>>

Best Practices

Getters should not have side effects:
// ✗ Bad - side effects in getter
get total() {
  console.log('Computing total')
  trackAnalytics('total_computed')
  return this.price * this.quantity
}

// ✓ Good - pure computation
get total() {
  return this.price * this.quantity
}
Keep getters fast, or break them into steps:
// ✗ Bad - expensive operation every time
get processedData() {
  return this.rawData
    .map(expensiveTransform)
    .filter(expensiveFilter)
    .sort(expensiveSort)
}

// ✓ Good - break into cacheable steps
get transformedData() {
  return this.rawData.map(expensiveTransform)
},

get filteredData() {
  return this.transformedData.filter(expensiveFilter)
},

get processedData() {
  return this.filteredData.sort(expensiveSort)
}
Don’t store what you can compute:
// ✗ Bad - storing derived state
const store = createStore({
  todos: [] as Array<Todo>,
  activeCount: 0, // Redundant!
})

// ✓ Good - compute derived state
const store = createStore({
  todos: [] as Array<Todo>,
  get activeCount() {
    return this.todos.filter(t => !t.done).length
  },
})
Add comments for non-obvious computations:
const store = createStore({
  transactions: [] as Transaction[],
  
  /**
   * Calculates the running balance by processing transactions
   * in chronological order and accounting for pending transactions
   */
  get currentBalance() {
    return this.transactions
      .filter(t => t.status !== 'pending')
      .reduce((balance, t) => 
        balance + (t.type === 'credit' ? t.amount : -t.amount),
        0
      )
  },
})

Common Pitfalls

Circular Dependencies

Avoid circular dependencies between getters:
// ✗ Bad - circular dependency
const store = createStore({
  get a() {
    return this.b + 1
  },
  get b() {
    return this.a + 1 // Infinite loop!
  },
})

Mutating State in Getters

Never mutate state inside getters:
// ✗ Bad - mutating in getter
get sortedItems() {
  this.items.sort() // Mutates original array!
  return this.items
}

// ✓ Good - create new array
get sortedItems() {
  return [...this.items].sort()
}

Next Steps

Custom Actions

Create complex state update logic

Performance

Optimize getter performance

TypeScript

Advanced TypeScript patterns

Testing

Test stores with computed values

Build docs developers (and LLMs) love