Skip to content

State Management

Fine-grained reactive state management for EreoJS.

Import

ts
import {
  Signal,
  Store,
  signal,
  atom,
  computed,
  batch,
  createStore,
  // React hooks
  useSignal,
  useStoreKey,
  useStore
} from '@ereo/state'

Overview

The @ereo/state package provides a signals-based reactivity system for managing application state. Signals are reactive primitives that automatically track dependencies and notify subscribers when values change.

Key features:

  • Fine-grained reactivity with minimal overhead
  • Automatic dependency tracking
  • Computed values that derive from other signals
  • batch() API for future update batching (placeholder)
  • Store abstraction for grouped state
  • Framework-agnostic with React integration

Signals and Reactivity

What is a Signal?

A signal is a reactive container for a value. When the value changes, all subscribers are automatically notified.

ts
import { signal } from '@ereo/state'

const count = signal(0)

// Read value
console.log(count.get()) // 0

// Write value
count.set(5)
console.log(count.get()) // 5

// React to changes
count.subscribe(value => {
  console.log('Count changed:', value)
})

count.set(10) // Logs: "Count changed: 10"

Reactivity Model

Signal A ──┐
           ├──> Computed C ──> Subscriber
Signal B ──┘

When Signal A or B changes:

  1. Computed C recalculates
  2. Subscriber receives new value

This creates a dependency graph that automatically updates.

API Reference

Signal Class

The core reactive primitive.

Constructor

ts
new Signal<T>(initialValue: T)

Methods

MethodSignatureDescription
get()() => TGet the current value
set(value)(value: T) => voidSet a new value
update(fn)(fn: (value: T) => T) => voidUpdate value with a function
subscribe(fn)(fn: (value: T) => void) => () => voidSubscribe to changes
map(fn)<U>(fn: (value: T) => U) => Signal<U>Create derived signal

Example

ts
const user = new Signal<User | null>(null)

// Get
const currentUser = user.get()

// Set
user.set({ id: '1', name: 'Alice' })

// Update
user.update(u => u ? { ...u, name: 'Bob' } : null)

// Subscribe
const unsubscribe = user.subscribe(u => {
  console.log('User:', u?.name)
})

// Unsubscribe
unsubscribe()

// Map
const userName = user.map(u => u?.name ?? 'Guest')

signal

Creates a new signal.

Signature

ts
function signal<T>(initialValue: T): Signal<T>

Parameters

NameTypeDescription
initialValueTThe initial value of the signal

Returns

A new Signal<T> instance.

Example

ts
import { signal } from '@ereo/state'

// Primitive values
const count = signal(0)
const name = signal('Alice')
const active = signal(true)

// Objects
const user = signal<User | null>(null)
const settings = signal({ theme: 'dark', lang: 'en' })

// Arrays
const items = signal<Item[]>([])

atom

Alias for signal. Use for semantic naming of global state atoms.

Signature

ts
function atom<T>(initialValue: T): Signal<T>

Example

ts
import { atom } from '@ereo/state'

// Use atom for global state
export const userAtom = atom<User | null>(null)
export const themeAtom = atom<'light' | 'dark'>('light')
export const cartAtom = atom<CartItem[]>([])

computed

Creates a computed signal derived from other signals.

Signature

ts
function computed<T>(
  fn: () => T,
  deps: Signal<unknown>[]
): Signal<T>

Parameters

NameTypeDescription
fn() => TFunction that computes the value
depsSignal<unknown>[]Array of signal dependencies

Returns

A new Signal<T> that automatically updates when dependencies change.

Example

ts
import { signal, computed } from '@ereo/state'

const firstName = signal('John')
const lastName = signal('Doe')

const fullName = computed(
  () => `${firstName.get()} ${lastName.get()}`,
  [firstName, lastName]
)

console.log(fullName.get()) // "John Doe"

firstName.set('Jane')
console.log(fullName.get()) // "Jane Doe"

batch

Wraps multiple signal updates for future batching support.

Note: The current implementation executes the function immediately without batching notifications. This is a placeholder for future optimization. Each set() call still triggers its own notification cycle. For now, use this to mark code that would benefit from batching when the feature is fully implemented.

Signature

ts
function batch<T>(fn: () => T): T

Parameters

NameTypeDescription
fn() => TFunction containing multiple updates

Returns

The return value of the function.

Current Behavior

ts
import { signal, batch } from '@ereo/state'

const a = signal(1)
const b = signal(2)

a.subscribe(value => console.log('A:', value))
b.subscribe(value => console.log('B:', value))

// Currently: still triggers two separate notifications
batch(() => {
  a.set(10) // Logs: "A: 10"
  b.set(20) // Logs: "B: 20"
})

Future Behavior (Planned)

When batching is fully implemented, subscribers will only be notified once after all updates complete:

ts
// Future: single notification after all updates
batch(() => {
  a.set(100)
  b.set(200)
})
// Will log once after both updates complete

Workaround for Atomic Updates

For truly atomic state changes, use a single signal with an object:

ts
const state = signal({ a: 1, b: 2 })

// Single notification for multiple value changes
state.update(s => ({ ...s, a: 100, b: 200 }))

Store Class

A container for multiple related signals.

Constructor

ts
new Store<T extends Record<string, unknown>>(initialState: T)

Methods

MethodSignatureDescription
get(key)<K extends keyof T>(key: K) => Signal<T[K]>Get signal for key
set(key, value)<K extends keyof T>(key: K, value: T[K]) => voidSet value for key
entries()() => IterableIterator<[string, Signal<unknown>]>Iterate over all signal entries
getSnapshot()() => TGet snapshot of all values

Example

ts
interface AppState {
  user: User | null
  theme: 'light' | 'dark'
  count: number
}

const store = new Store<AppState>({
  user: null,
  theme: 'light',
  count: 0
})

// Get signal
const userSignal = store.get('user')
userSignal.subscribe(user => console.log('User:', user))

// Set value
store.set('theme', 'dark')
store.set('count', 42)

// Get snapshot
const state = store.getSnapshot()
// { user: null, theme: 'dark', count: 42 }

createStore

Factory function to create a store.

Signature

ts
function createStore<T extends Record<string, unknown>>(
  initialState: T
): Store<T>

Parameters

NameTypeDescription
initialStateTObject with initial values

Returns

A new Store<T> instance.

Example

ts
import { createStore } from '@ereo/state'

const store = createStore({
  user: null as User | null,
  notifications: [] as Notification[],
  settings: {
    theme: 'light',
    language: 'en'
  }
})

Creating and Using Signals

Basic Patterns

ts
// Counter
const count = signal(0)
count.set(count.get() + 1)
// or
count.update(c => c + 1)

// Toggle
const active = signal(false)
active.update(a => !a)

// List operations
const items = signal<string[]>([])
items.update(list => [...list, 'new item'])
items.update(list => list.filter(i => i !== 'remove'))

// Object updates
const user = signal({ name: 'Alice', age: 30 })
user.update(u => ({ ...u, age: 31 }))

Type-Safe Signals

ts
interface User {
  id: string
  name: string
  email: string
}

// Nullable signal
const currentUser = signal<User | null>(null)

// Union types
const status = signal<'idle' | 'loading' | 'success' | 'error'>('idle')

// Generic signals
function createLoadingSignal<T>() {
  return signal<{
    loading: boolean
    data: T | null
    error: Error | null
  }>({
    loading: false,
    data: null,
    error: null
  })
}

Computed Values and Effects

Derived State

ts
const items = signal([
  { name: 'Apple', price: 1.00, quantity: 3 },
  { name: 'Banana', price: 0.50, quantity: 5 }
])

const total = computed(
  () => items.get().reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  ),
  [items]
)

const itemCount = computed(
  () => items.get().reduce((sum, item) => sum + item.quantity, 0),
  [items]
)

console.log(total.get())     // 5.50
console.log(itemCount.get()) // 8

Chained Computations

ts
const celsius = signal(20)

const fahrenheit = computed(
  () => celsius.get() * 9/5 + 32,
  [celsius]
)

const description = computed(
  () => {
    const f = fahrenheit.get()
    if (f < 32) return 'Freezing'
    if (f < 60) return 'Cold'
    if (f < 80) return 'Warm'
    return 'Hot'
  },
  [fahrenheit]
)

celsius.set(35)
console.log(fahrenheit.get())  // 95
console.log(description.get()) // "Hot"

Effects with Subscriptions

ts
const theme = signal<'light' | 'dark'>('light')

// Effect: update DOM
const unsubscribe = theme.subscribe(value => {
  document.documentElement.setAttribute('data-theme', value)
})

// Effect: log changes
theme.subscribe(value => {
  console.log('Theme changed to:', value)
})

// Cleanup when done
unsubscribe()

State Persistence

localStorage Persistence

ts
function persistedSignal<T>(key: string, initialValue: T): Signal<T> {
  // Load from storage
  const stored = localStorage.getItem(key)
  const initial = stored ? JSON.parse(stored) : initialValue

  const sig = signal(initial)

  // Save on changes
  sig.subscribe(value => {
    localStorage.setItem(key, JSON.stringify(value))
  })

  return sig
}

// Usage
const theme = persistedSignal('theme', 'light')
const favorites = persistedSignal<string[]>('favorites', [])

Session Storage

ts
function sessionSignal<T>(key: string, initialValue: T): Signal<T> {
  const stored = sessionStorage.getItem(key)
  const initial = stored ? JSON.parse(stored) : initialValue

  const sig = signal(initial)

  sig.subscribe(value => {
    sessionStorage.setItem(key, JSON.stringify(value))
  })

  return sig
}

Persisted Store

ts
function createPersistedStore<T extends Record<string, unknown>>(
  key: string,
  initialState: T
): Store<T> {
  const stored = localStorage.getItem(key)
  const initial = stored ? JSON.parse(stored) : initialState

  const store = createStore<T>(initial)

  // Subscribe to all keys
  Object.keys(initial).forEach(k => {
    store.get(k as keyof T).subscribe(() => {
      localStorage.setItem(key, JSON.stringify(store.getSnapshot()))
    })
  })

  return store
}

// Usage
const settings = createPersistedStore('settings', {
  theme: 'light',
  language: 'en',
  notifications: true
})

Integration with Components

React Hooks

The @ereo/state package includes three React hooks built on useSyncExternalStore. These are the recommended way to use signals and stores in React components — you do not need to write custom hooks.

useSignal

Subscribes a component to a signal. The component re-renders only when the signal's value changes.

tsx
import { signal, useSignal } from '@ereo/state'

const count = signal(0)

function Counter() {
  const value = useSignal(count)

  return (
    <button onClick={() => count.update(c => c + 1)}>
      Count: {value}
    </button>
  )
}

useStoreKey

Subscribes to a single key in a store. Only re-renders when that specific key changes — more efficient than subscribing to the entire store.

tsx
import { createStore, useStoreKey } from '@ereo/state'

const appStore = createStore({ theme: 'light' as 'light' | 'dark', count: 0 })

function ThemeToggle() {
  const theme = useStoreKey(appStore, 'theme')

  return (
    <button onClick={() => appStore.set('theme', theme === 'light' ? 'dark' : 'light')}>
      Theme: {theme}
    </button>
  )
}

useStore

Subscribes to the entire store snapshot. Re-renders when any key changes. Use useStoreKey instead when you only need one or two keys.

tsx
import { createStore, useStore } from '@ereo/state'

const appStore = createStore({ count: 0, name: 'Alice' })

function Dashboard() {
  const state = useStore(appStore)
  return <div>{state.count} - {state.name}</div>
}

Two-Way Binding Pattern

For form inputs, you can combine useSignal with the signal's set method:

tsx
import { signal, useSignal } from '@ereo/state'

const nameSignal = signal('')

function NameInput() {
  const name = useSignal(nameSignal)

  return (
    <input
      value={name}
      onChange={e => nameSignal.set(e.target.value)}
    />
  )
}

Island Communication

Signals enable communication between separate islands:

tsx
// lib/shared.ts
import { signal } from '@ereo/state'

export const selectedId = signal<string | null>(null)

// islands/List.tsx
import { selectedId } from '../lib/shared'

export default function List({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li
          key={item.id}
          onClick={() => selectedId.set(item.id)}
        >
          {item.name}
        </li>
      ))}
    </ul>
  )
}

// islands/Detail.tsx
import { selectedId } from '../lib/shared'
import { useSignal } from '@ereo/state'

export default function Detail() {
  const id = useSignal(selectedId)

  if (!id) return <p>Select an item</p>

  return <ItemDetail id={id} />
}

Patterns

Global State Module

ts
// lib/store.ts
import { signal, computed } from '@ereo/state'

// State
export const user = signal<User | null>(null)
export const cartItems = signal<CartItem[]>([])
export const theme = signal<'light' | 'dark'>('light')

// Derived state
export const isLoggedIn = computed(() => user.get() !== null, [user])
export const cartTotal = computed(
  () => cartItems.get().reduce((sum, item) => sum + item.price * item.quantity, 0),
  [cartItems]
)
export const cartCount = computed(
  () => cartItems.get().reduce((sum, item) => sum + item.quantity, 0),
  [cartItems]
)

Actions Module

ts
// lib/actions.ts
import { user, cartItems, theme } from './store'

export function login(userData: User) {
  user.set(userData)
}

export function logout() {
  user.set(null)
  cartItems.set([])
}

export function addToCart(item: CartItem) {
  cartItems.update(items => {
    const existing = items.find(i => i.id === item.id)
    if (existing) {
      return items.map(i =>
        i.id === item.id
          ? { ...i, quantity: i.quantity + 1 }
          : i
      )
    }
    return [...items, { ...item, quantity: 1 }]
  })
}

export function removeFromCart(itemId: string) {
  cartItems.update(items => items.filter(i => i.id !== itemId))
}

export function toggleTheme() {
  theme.update(t => t === 'light' ? 'dark' : 'light')
}

Async State Pattern

ts
interface AsyncState<T> {
  loading: boolean
  data: T | null
  error: Error | null
}

function createAsyncSignal<T>(fetcher: () => Promise<T>) {
  const state = signal<AsyncState<T>>({
    loading: false,
    data: null,
    error: null
  })

  const load = async () => {
    state.set({ loading: true, data: null, error: null })

    try {
      const data = await fetcher()
      state.set({ loading: false, data, error: null })
    } catch (error) {
      state.set({ loading: false, data: null, error: error as Error })
    }
  }

  const reset = () => {
    state.set({ loading: false, data: null, error: null })
  }

  return { state, load, reset }
}

// Usage
const { state: users, load: loadUsers } = createAsyncSignal(() =>
  fetch('/api/users').then(r => r.json())
)

loadUsers()

Form State Pattern

ts
function createFormSignal<T extends Record<string, unknown>>(initialValues: T) {
  const values = signal(initialValues)
  const errors = signal<Partial<Record<keyof T, string>>>({})
  const touched = signal<Partial<Record<keyof T, boolean>>>({})

  const isValid = computed(
    () => Object.keys(errors.get()).length === 0,
    [errors]
  )

  return {
    values,
    errors,
    touched,
    isValid,

    setValue<K extends keyof T>(field: K, value: T[K]) {
      values.update(v => ({ ...v, [field]: value }))
    },

    setError<K extends keyof T>(field: K, error: string | null) {
      errors.update(e => {
        if (error) {
          return { ...e, [field]: error }
        }
        const { [field]: _, ...rest } = e
        return rest as typeof e
      })
    },

    setTouched<K extends keyof T>(field: K) {
      touched.update(t => ({ ...t, [field]: true }))
    },

    reset() {
      values.set(initialValues)
      errors.set({})
      touched.set({})
    }
  }
}

Best Practices

1. Keep Signals Granular

Create separate signals for independent values:

ts
// Good: granular signals
const firstName = signal('')
const lastName = signal('')
const email = signal('')

// Avoid: monolithic signal
const form = signal({ firstName: '', lastName: '', email: '' })

2. Use Computed for Derivations

Never duplicate state:

ts
// Good: derived from source
const items = signal<Item[]>([])
const total = computed(() => items.get().reduce((s, i) => s + i.price, 0), [items])

// Avoid: separate state that can drift
const items = signal<Item[]>([])
const total = signal(0) // must be manually kept in sync

For truly atomic updates, use a single signal with an object:

ts
// Good: single signal for related values = single notification
const name = signal({ first: 'John', last: 'Doe' })
name.update(n => ({ ...n, first: 'Jane', last: 'Smith' }))

// Alternative: use batch() to mark intent (batching coming soon)
const firstName = signal('John')
const lastName = signal('Doe')
batch(() => {
  firstName.set('Jane')
  lastName.set('Smith')
})
// Note: currently triggers two notifications, future versions will batch

4. Clean Up Subscriptions

Always unsubscribe when done:

ts
// In React
useEffect(() => {
  return signal.subscribe(setValue) // cleanup on unmount
}, [])

// Manual cleanup
const unsubscribe = signal.subscribe(handler)
// Later...
unsubscribe()

5. Type Your State

Use TypeScript for safety:

ts
interface AppState {
  user: User | null
  theme: 'light' | 'dark'
  notifications: Notification[]
}

const store = createStore<AppState>({
  user: null,
  theme: 'light',
  notifications: []
})

Released under the MIT License.