Skip to main content

How Subscriptions Work

Stan.js uses a sophisticated subscription system to notify components and listeners when state changes. The system is designed to be:
  • Selective - Only subscribe to the state keys you actually use
  • Efficient - Avoid unnecessary re-renders and callback executions
  • Flexible - Works in React and vanilla JavaScript environments

React Subscriptions

Automatic Subscription Tracking

When you use the useStore hook, Stan.js automatically tracks which state properties you access:
const { useStore } = createStore({
  count: 0,
  message: 'Hello',
  user: { name: 'John' },
})

function Component() {
  const { count, setCount } = useStore()
  
  // This component only subscribes to 'count'
  // Changes to 'message' or 'user' won't trigger re-renders
  return <div>{count}</div>
}

How Tracking Works

From src/createStore.ts:62-75, Stan.js uses a Proxy to detect property access:
return new Proxy({ ...synced, ...store.actions } as UseStoreReturn, {
  get: (target, key) => {
    if (storeKeys.includes(key as TKey) && !subscribeKeys.has(key as TKey)) {
      subscribeKeys.add(key as TKey)
      setIsInitialized(true)
    }

    if (keyInObject(key, target)) {
      return target[key]
    }

    return undefined
  },
})
The Proxy wrapper only exists during the first render. Once all accessed keys are tracked, the hook returns the actual state object for better performance.

useSyncExternalStore Integration

Stan.js uses React’s useSyncExternalStore for subscriptions (src/createStore.ts:56):
const synced = useSyncExternalStore(subscribeStore, getSnapshot, getSnapshot)
This ensures:
  • Concurrent mode safety - Works with React 18+ concurrent features
  • SSR support - Proper server-side rendering behavior
  • Tearing prevention - Consistent state across component tree

Vanilla JavaScript Subscriptions

The subscribe Function

For non-React environments, use the subscribe function:
const { subscribe, getState } = createStore({
  count: 0,
  message: 'Hello',
})

// Subscribe to specific keys
const unsubscribe = subscribe(['count'])(() => {
  console.log('Count changed:', getState().count)
})

// Later, unsubscribe
unsubscribe()

Implementation Details

From src/vanilla/createStore.ts:141-149, the subscribe function:
const subscribe = (keys: Array<TKey>) => (listener: VoidFunction) => {
  const compositeKey = keys.join('\0')
  listeners[compositeKey] ??= []
  listeners[compositeKey]?.push(listener)

  return () => {
    listeners[compositeKey] = listeners[compositeKey]?.filter(l => l !== listener) ?? []
  }
}
Stan.js uses the null character (\0) to create composite keys for multi-key subscriptions. This allows efficient lookup of all listeners that care about a specific key.

The Effect Hook

The effect function provides a reactive way to respond to state changes:
const { effect } = createStore({
  count: 0,
  message: 'Hello',
})

// Automatically tracks accessed properties
const dispose = effect((state) => {
  console.log('Count or message changed:', state.count, state.message)
  
  // Runs when count or message changes
  document.title = `Count: ${state.count}`
})

// Clean up
dispose()

Effect Implementation

From src/vanilla/createStore.ts:207-227, effects use dependency tracking:
const effect = (run: (state: TState) => void) => {
  const keysToListen = new Set<TKey>()

  run(
    new Proxy(state, {
      get: (target, key) => {
        if (storeKeys.includes(key as TKey)) {
          keysToListen.add(key as TKey)
        }

        if (keyInObject(key, target)) {
          return target[key]
        }

        return undefined
      },
    }),
  )

  return subscribe(keysToListen.size === 0 ? storeKeys : Array.from(keysToListen))(() => run(state))
}

React Effect Hook

For React components, use useStoreEffect:
const { useStoreEffect } = createStore({
  count: 0,
})

function Component() {
  useStoreEffect((state) => {
    console.log('Count changed:', state.count)
    
    // Side effects here
    document.title = `Count: ${state.count}`
  })
  
  return <div>...</div>
}
useStoreEffect runs on mount and whenever tracked state changes. Be careful with side effects that shouldn’t run on every change.

Notification System

When Notifications Happen

Notifications are triggered when state values change (src/vanilla/createStore.ts:57-69):
const notifyUpdates = (keyToNotify: TKey) => {
  Object.entries(listeners).forEach(([compositeKey, listenersArray]) => {
    if (compositeKey.split('\0').every(key => key !== keyToNotify)) {
      return
    }

    if (isBatching) {
      return batchedKeys.add(compositeKey)
    }

    listenersArray.forEach(listener => listener(state[compositeKey as TKey]))
  })
}

Notification Flow

Batched Notifications

Why Batching Matters

Without batching, multiple state updates trigger multiple notifications:
const { actions } = createStore({
  firstName: '',
  lastName: '',
  email: '',
})

function Component() {
  const { firstName, lastName, email } = useStore()
  
  // This component re-renders 3 times!
  const updateUser = () => {
    actions.setFirstName('John')
    actions.setLastName('Doe')
    actions.setEmail('[email protected]')
  }
}

Using batchUpdates

function Component() {
  const { firstName, lastName, email } = useStore()
  const { batchUpdates } = store
  
  // This component re-renders once!
  const updateUser = () => {
    batchUpdates(() => {
      actions.setFirstName('John')
      actions.setLastName('Doe')
      actions.setEmail('[email protected]')
    })
  }
}

Batch Implementation

From src/vanilla/createStore.ts:71-82:
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 batch all updates, so you don’t need to manually call batchUpdates when using them.

Composite Key Subscriptions

Stan.js allows subscribing to multiple keys simultaneously:
const { subscribe } = createStore({
  firstName: 'John',
  lastName: 'Doe',
  age: 30,
})

// Listen to firstName OR lastName changes
subscribe(['firstName', 'lastName'])(() => {
  console.log('Name changed!')
})

// Age changes won't trigger this listener
Keys are joined with null bytes internally:
const compositeKey = keys.join('\0') // 'firstName\0lastName'

Synchronizers and Subscriptions

Synchronizers integrate with the subscription system for external state sources:
const storage = (initial: number) => ({
  value: initial,
  getSnapshot: (key: string) => {
    const value = localStorage.getItem(key)
    return value ? JSON.parse(value) : null
  },
  update: (value: number, key: string) => {
    localStorage.setItem(key, JSON.stringify(value))
  },
  subscribe: (update: (value: number) => void, key: string) => {
    window.addEventListener('storage', (event) => {
      if (event.key === key && event.newValue) {
        update(JSON.parse(event.newValue))
      }
    })
  },
})

const { useStore } = createStore({
  count: storage(0),
})
From src/vanilla/createStore.ts:129-132, synchronizers hook into listeners:
listeners[key]?.push(newValue => value.update(newValue, key))
value.subscribe?.(getAction(key as TKey), key)

Performance Optimizations

Selective Subscriptions

Only subscribe to what you use:
function CountDisplay() {
  // Only subscribes to 'count'
  const { count } = useStore()
  return <div>{count}</div>
}

function MessageDisplay() {
  // Only subscribes to 'message'
  const { message } = useStore()
  return <div>{message}</div>
}

Equality Checks

Stan.js skips notifications when values don’t change:
// From src/vanilla/createStore.ts:42-43
if (equal(state[key], value)) {
  return
}

Memoized Snapshots

For React, snapshots are memoized to prevent unnecessary renders (src/createStore.ts:21-35):
const getState = () => {
  let oldState: TState

  return () => {
    const currentState = { ...store.getState() }

    if (equal(oldState, currentState)) {
      return oldState // Same reference = no re-render
    }

    oldState = currentState
    return currentState
  }
}

Common Patterns

Global State Listener

const { effect } = createStore({
  theme: 'light',
  user: null,
})

// Log all state changes
effect((state) => {
  console.log('State changed:', state)
})

Cleanup on Unmount

function Component() {
  const { effect } = store
  
  useEffect(() => {
    const dispose = effect((state) => {
      // React to changes
    })
    
    return dispose // Cleanup
  }, [])
}

Conditional Subscriptions

function Component({ shouldTrack }: { shouldTrack: boolean }) {
  const { useStoreEffect } = store
  
  useStoreEffect(
    (state) => {
      if (shouldTrack) {
        trackEvent('count_changed', { count: state.count })
      }
    },
    [shouldTrack]
  )
}

Best Practices

Only access state properties you actually need:
// ✗ Bad - subscribes to all state
const store = useStore()
return <div>{store.count}</div>

// ✓ Good - subscribes only to count
const { count } = useStore()
return <div>{count}</div>
Always clean up manual subscriptions:
useEffect(() => {
  const unsubscribe = subscribe(['count'])(() => {
    // Handle change
  })
  
  return unsubscribe // Important!
}, [])
Effects run on every change. Use them only when necessary:
// ✗ Bad - effect for simple logging
useStoreEffect((state) => {
  console.log(state.count)
})

// ✓ Good - regular useEffect
const { count } = useStore()
useEffect(() => {
  console.log(count)
}, [count])

Next Steps

Computed Values

Learn about derived state with getters

Custom Actions

Create custom actions with auto-batching

Performance

Optimize your Stan.js applications

Persistence

Persist state with Synchronizers

Build docs developers (and LLMs) love