npm package: @sladg/apex-state
Description: Advanced state management wrapper around Valtio with sync paths, aggregations, and side effects
Author: Jan Soukup
Latest version: 4.7.0

# @sladg/apex-state

Reactive state management for React built on [Valtio](https://github.com/pmndrs/valtio). Declare what your fields need — validation, conditional UI, sync, listeners — and the store handles the rest. Rust/WASM pipeline handles all heavy computation: graph traversal, expression evaluation, listener dispatch.

## Quick Start

```bash
npm install @sladg/apex-state valtio zod react
```

```tsx
import { createGenericStore } from '@sladg/apex-state'
import { z } from 'zod'

// 1. Define your state shape
type FormState = {
  user: { name: string; email: string; age: number }
  preferences: { newsletter: boolean; theme: 'light' | 'dark' }
}

// 2. Create a typed store (WASM-accelerated by default)
const store = createGenericStore<FormState>()

// 3. Wrap your app with Provider
const App = () => (
  <store.Provider initialState={{
    user: { name: '', email: '', age: 0 },
    preferences: { newsletter: false, theme: 'light' },
  }}>
    <UserForm />
  </store.Provider>
)

// 4. Use hooks to read/write state and declare concerns
const UserForm = () => {
  store.useConcerns('user-form', {
    'user.email': {
      validationState: { schema: z.string().email('Invalid email') },
    },
  })

  const { value, setValue, validationState } = store.useFieldStore('user.email')

  return (
    <div>
      <input
        value={value}
        onChange={(e) => setValue(e.target.value)}
        className={validationState?.isError ? 'error' : ''}
      />
      {validationState?.isError && <span>{validationState.errors[0]?.message}</span>}
    </div>
  )
}
```

## Features

| Feature | Description | Details |
|---|---|---|
| **Type-safe paths** | `DeepKey<T>` / `DeepValue<T, P>` — compile-time path safety with configurable depth | |
| **Concerns** | Validation (Zod), BoolLogic conditions, ValueLogic conditional values, dynamic text | [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) |
| **Side effects** | Sync paths, flip paths, aggregations, listeners | [Side Effects Guide](docs/SIDE_EFFECTS_GUIDE.md) |
| **WASM mode** | Rust-powered pipeline for bulk operations (full catalog refresh in 7.4ms) | [Architecture](docs/WASM_ARCHITECTURE.md) |
| **Composable hooks** | Buffered, throttled, transformed field wrappers | [Store & Hooks](docs/guides/STORE_HOOKS.md) |
| **Record/wildcard** | `Record<string, V>` with `_()` hash key paths | [Wildcard Guide](docs/WILD_FUNCTION_GUIDE.md) |
| **Testing mock** | Drop-in `vi.mock` replacement with call tracking | [Testing Mock](docs/TESTING_MOCK.md) |

**Side effects** enforce data invariants — when X changes, Y must follow (sync, flip, aggregate, listen). **Concerns** annotate fields with UI meaning — given settled state, is this field valid, disabled, visible? They run at different times and write to different proxies; neither drives the other.

Side effects and declarative concerns (`boolLogic`, `valueLogic`, `validationState`) are evaluated **synchronously inside the WASM pipeline** — results are available immediately after the state change. Custom `evaluate()` concerns use valtio's `effect()` and are **not guaranteed synchronous** — valtio may batch or defer their evaluation to the next microtask.

## Full Example

```tsx
import { createGenericStore } from '@sladg/apex-state'
import { z } from 'zod'

type OrderState = {
  product: { name: string; quantity: number; price: number }
  shipping: { address: string; express: boolean; standard: boolean }
  payment: { method: 'card' | 'cash'; cardNumber: string }
  status: 'draft' | 'submitted'
}

const store = createGenericStore<OrderState>()

const OrderForm = () => {
  // Side effects: auto-flip booleans
  store.useSideEffects('order', {
    flipPaths: [['shipping.express', 'shipping.standard']],
  })

  // Concerns: declarative validation and conditional UI
  store.useConcerns('order', {
    'product.quantity': {
      validationState: { schema: z.number().min(1).max(100) },
      disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'submitted'] } },
    },
    'payment.cardNumber': {
      validationState: { schema: z.string().regex(/^\d{16}$/) },
      visibleWhen: { boolLogic: { IS_EQUAL: ['payment.method', 'card'] } },
    },
  })

  const { value, setValue, validationState, disabledWhen } =
    store.useFieldStore('product.quantity')

  return (
    <input
      type="number"
      value={value}
      onChange={(e) => setValue(Number(e.target.value))}
      disabled={disabledWhen}
      className={validationState?.isError ? 'error' : ''}
    />
  )
}

const App = () => (
  <store.Provider initialState={{
    product: { name: 'Widget', quantity: 1, price: 29.99 },
    shipping: { address: '', express: false, standard: true },
    payment: { method: 'card', cardNumber: '' },
    status: 'draft',
  }}>
    <OrderForm />
  </store.Provider>
)
```

## Concerns

Concerns describe *what a field means* for the UI given the current state — valid, disabled, visible, what label to show. They run after state settles and write to a separate `_concerns` proxy, never to `state` itself.

```tsx
store.useConcerns('checkout', {
  // Zod validation
  'user.email': {
    validationState: { schema: z.string().email('Enter a valid email') },
    disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'submitted'] } },
    dynamicTooltip: { template: 'Sending confirmation to {{user.email}}' },
  },

  // Card number: validate + show only when paying by card
  'payment.cardNumber': {
    validationState: { schema: z.string().regex(/^\d{16}$/, 'Must be 16 digits') },
    visibleWhen: { boolLogic: { IS_EQUAL: ['payment.method', 'card'] } },
  },

  // Lock order total above threshold
  'order.total': {
    readonlyWhen: { boolLogic: { GT: ['order.total', 10000] } },
  },

  // ValueLogic: label adapts to stock level
  'product.quantity': {
    dynamicLabel: {
      valueLogic: {
        IF: { LTE: ['product.quantity', 5] },
        THEN: 'Quantity (low stock)',
        ELSE: 'Quantity',
      },
    },
  },
})

// Read all concerns for a field via useFieldStore
const { value, setValue, validationState, disabledWhen, dynamicTooltip } =
  store.useFieldStore('user.email')

// Available BoolLogic operators:
// IS_EQUAL, EXISTS, IS_EMPTY, GT, LT, GTE, LTE, IN, AND, OR, NOT
```

## Side Effects

Side effects describe *what must change* when state changes — enforcing invariants, keeping related fields in sync, reacting to mutations. They run inside the pipeline and can write back to `state`.

```tsx
store.useSideEffects('checkout', {
  // Sync billing ↔ shipping contact details bidirectionally
  syncPaths: [
    ['billing.email', 'shipping.email'],
    ['billing.phone', 'shipping.phone'],
  ],

  // Express and standard shipping are mutually exclusive
  flipPaths: [
    ['shipping.express', 'shipping.standard'],
  ],

  // Order total = consensus across items (undefined if they differ)
  aggregations: [
    ['order.total', 'items.0.price'],
    ['order.total', 'items.1.price'],
    ['order.total', 'items.2.price'],
  ],

  // Stamp audit trail on any profile change
  listeners: [
    {
      path: 'user',
      scope: 'user',
      fn: (_changes, _state) => [['audit.lastModified', Date.now(), {}]],
    },
  ],
})
```

## Reading and Writing State

```tsx
// useStore — simple [value, setter] tuple (like useState)
const [name, setName] = store.useStore('user.name')

// useFieldStore — object API with all concerns merged in
const { value, setValue, validationState, disabledWhen } =
  store.useFieldStore('user.email')

// useJitStore — bulk updates and non-reactive reads
const { setChanges } = store.useJitStore()
setChanges([
  ['user.name', 'Alice', {}],
  ['user.email', 'alice@example.com', {}],
])
```

## Composable Field Hooks

```tsx
import { useBufferedField, useThrottledField, useTransformedField } from '@sladg/apex-state'

// Buffer edits locally, commit/cancel explicitly
const raw = store.useFieldStore('product.price')
const buffered = useBufferedField(raw)
// buffered.value, buffered.setValue, buffered.commit(), buffered.cancel(), buffered.isDirty

// Throttle rapid updates (e.g. range sliders)
const throttled = useThrottledField(raw, { ms: 100 })

// Transform display format (cents ↔ dollars)
const formatted = useTransformedField(raw, {
  to: (cents: number) => (cents / 100).toFixed(2),
  from: (dollars: string) => Math.round(parseFloat(dollars) * 100),
})

// Chain: buffered + transformed
const display = useTransformedField(useBufferedField(raw), {
  to: (cents) => (cents / 100).toFixed(2),
  from: (dollars) => Math.round(parseFloat(dollars) * 100),
})
```

## Hash Key Paths for Record Types

```tsx
import { _ } from '@sladg/apex-state'

// _() marks a segment as a hash key for Record-typed paths
const itemPath = `catalog.categories.${_('c1')}.items`
// -> typed string containing HASH_KEY marker

// Use with concerns — applies to ALL keys in the Record
store.useConcerns('wildcards', {
  [itemPath]: {
    validationState: { schema: z.number().min(0) },
    disabledWhen: { boolLogic: { IS_EQUAL: ['status', 'locked'] } },
  },
})

// Multiple hash keys for deeply nested Records
const nestedPath = `catalog.${_('c1')}.products.${_('p1')}.variants.${_('v1')}.price`
```

## Type-Safe Paths with Configurable Depth

Deeply nested state types can cause TypeScript errors like **TS2589 ("Type instantiation is excessively deep")** or slow IDE autocomplete. `DeepKey<T, Depth>` solves this with a configurable recursion limit — reduce depth for wide types to speed up compilation, increase it for deeply nested structures, and get clear feedback when the limit is reached.

```typescript
type State = {
  user: { name: string; address: { street: string; city: string } }
  count: number
}

// DeepKey<State> = "user" | "count" | "user.name" | "user.address" | "user.address.street" | "user.address.city"
type AllPaths = DeepKey<State>
```

### Default Depth

`DefaultDepth` is set to **20** — enough for most real-world types. Uses loop unrolling (2 levels per recursion call) to stay well within TypeScript's recursion limits while supporting deeply nested structures.

### Custom Depth

Tune the depth limit per use-site to balance **compilation speed vs path coverage**:

```typescript
// Shallow — faster tsc for wide types with many top-level keys
type ShallowPaths = DeepKey<State, 10>

// Deeper — for types nested beyond 20 levels (e.g., recursive data models)
type DeepPaths = DeepKey<State, 30>
```

### Depth Limit Marker (`??`)

When `DeepKey` hits the depth limit on an object type, or encounters an **`any`-typed property** (unknowable structure), it emits a `??` marker in IDE autocomplete:

```
some.deep.path.??    ← depth limit reached
data.payload.??      ← property typed as `any`
```

This tells you exactly where path generation stopped. To fix it: increase depth (`DeepKey<T, 30>`), restructure your type, or replace `any` with a concrete type.

### Depth Propagation

The `Depth` parameter propagates to all dependent types, keeping the entire type system consistent:

```typescript
// All accept a Depth parameter (defaults to DefaultDepth = 20)
type Paths = DeepKey<State, 10>
type BoolPaths = DeepKeyFiltered<State, boolean, 10>
type Syncs = SyncPair<State, 10>
type Logic = BoolLogic<State, 10>
type Effects = SideEffects<State, Meta, 10>
type Clear = ClearPathRule<State, 10>
```

## Testing with the Mock Module

```tsx
// __mocks__/@sladg/apex-state.ts
export * from '@sladg/apex-state/testing'

// your-test.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { __mocked, createGenericStore } from '@sladg/apex-state/testing'
import { renderHook } from '@testing-library/react'

vi.mock('@sladg/apex-state')

type FormState = { user: { email: string; name: string }; count: number }

beforeEach(() => __mocked.reset())

it('seeds state with type-safe chaining', () => {
  __mocked
    .set<FormState>({ user: { email: '', name: '' }, count: 0 })
    .set('user.email', 'alice@test.com')
    .set('count', 42)

  expect(__mocked.getState()).toEqual({
    user: { email: 'alice@test.com', name: '' },
    count: 42,
  })
})

it('tracks setValue calls from hooks', () => {
  const store = createGenericStore<FormState>()
  const hook = renderHook(() => store.useStore('user.email'))

  hook.result.current[1]('bob@test.com')

  expect(__mocked.state.calls).toContainEqual({
    path: 'user.email',
    value: 'bob@test.com',
    meta: undefined,
  })
})

it('tracks concern and side effect registrations', () => {
  const store = createGenericStore<FormState>()
  store.useConcerns('form', { 'user.email': { disabledWhen: { boolLogic: { AND: [] } } } })
  store.useSideEffects('effects', { listeners: [] })

  expect(__mocked.state.effects).toHaveLength(2)
  expect(__mocked.state.effects[0]?.type).toBe('concerns')
  expect(__mocked.state.effects[1]?.type).toBe('sideEffects')
})
```

## Performance

Benchmarks run on Apple M4 Pro against a full e-commerce pipeline: 75 sync pairs, 40 flip pairs, 100 BoolLogic expressions, 85 listeners with real JS handlers.

| Scenario | Changes | Duration |
|---|---|---|
| Single field change | 1 | 1.4µs |
| Order confirmation | 1 | 40µs |
| Dashboard aggregation (10 orders) | 10 | 295µs |
| Bulk price update (3 Record levels deep) | 60 | 7.2ms |
| Full catalog refresh (everything fires) | 135 | 7.4ms |

Typical interactions are sub-millisecond. The stress case — 135 changes triggering cascading sync, flip, BoolLogic, and 85 listener callbacks across 3 Record levels — completes in **7.4ms**.

**Bundle:** ~105 KB JS + ~697 KB WASM, loaded separately and cached by the browser. For eager loading:

```ts
import '@sladg/apex-state/preload'
```

See [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) for the full analysis.

## API Overview

### Store Creation

`createGenericStore<T>(config?)` returns all hooks:

| Hook | Returns | Use case |
|---|---|---|
| `Provider` | React context | Wraps component tree with `initialState` |
| `useStore(path)` | `[value, setValue]` | Simple read/write (like `useState`) |
| `useFieldStore(path)` | `{ value, setValue, ...concerns }` | Form fields with merged concerns |
| `useJitStore()` | `{ proxyValue, setChanges, getState }` | Bulk updates, non-reactive reads |
| `useConcerns(id, config)` | `void` | Register validation/BoolLogic/dynamic text |
| `useSideEffects(id, config)` | `void` | Register sync/flip/aggregation/listeners |
| `withConcerns(selection)` | `{ useFieldStore }` | Scoped field store with selected concerns |
| `withMeta(presetMeta)` | `{ useFieldStore }` | Field store with preset META values |

### Built-in Concerns

| Concern | Returns | Config |
|---|---|---|
| `validationState` | `{ isError, errors[] }` | `{ schema: ZodSchema }` |
| `disabledWhen` | `boolean` | `{ boolLogic: BoolLogic }` |
| `visibleWhen` | `boolean` | `{ boolLogic: BoolLogic }` |
| `readonlyWhen` | `boolean` | `{ boolLogic: BoolLogic }` |
| `dynamicLabel` | `string` | `{ template: '{{path}}' }` |
| `dynamicTooltip` | `string` | `{ template: '{{path}}' }` |
| `dynamicPlaceholder` | `string` | `{ template: '{{path}}' }` |

### BoolLogic Operators

`IS_EQUAL`, `EXISTS`, `IS_EMPTY`, `GT`, `LT`, `GTE`, `LTE`, `IN`, `AND`, `OR`, `NOT`

## Architecture

```
setValue("email", "alice@example.com")
  |
  v
[WASM/Rust] single processChanges() call:
  - shadow state update
  - aggregation → sync → flip
  - expression evaluation (BoolLogic + ValueLogic, unified)
  - listener waves + validator dispatch (via externref — no JS round-trips)
  |
  v
[JS] partition result changes by meta flag (state vs _concerns)
  |
  ├── applyBatch(stateChanges) → valtio state proxy
  └── applyConcernChanges(concernChanges) → valtio _concerns proxy
  |
  v
React re-render
```

**Dual-layer design:** JS/React owns reactivity and rendering. Rust/WASM owns the full pipeline — shadow state, sync/flip graphs, expression evaluation, and listener dispatch (via externref callbacks). The JS layer normalizes inputs, calls one WASM function, and applies the result. The boundary is thin: paths as strings, values as JSON.

**Side effects vs. concerns:** These are two distinct systems with different authority and different timing.

- **Side effects** model *causality* — because X changed, Y must change. They enforce invariants in the data model (sync, flip, aggregate, listen). They run *during* the pipeline and can write to `state`.
- **Concerns** model *interpretation* — given settled state, what does this field mean for the UI? They annotate fields with metadata (valid, disabled, visible, label). They run *after* the pipeline and can only write to `_concerns`.

Both use the same expression language (`BoolLogic`, `ValueLogic`) — that's just a shared way to write declarative conditions. What those conditions *drive* is determined by the registration context, not the expression itself.

See [Architecture Guide](docs/guides/ARCHITECTURE.md) and [WASM Architecture](docs/WASM_ARCHITECTURE.md) for the full specification.

## Development

```bash
npm install            # Install dependencies
npm run wasm:build     # Compile Rust -> WASM
npm run build          # Bundle TypeScript + WASM
npm run test           # Run tests
npm run code:check     # Lint + type check
npm run wasm:check     # Rust lint + check
```

### WASM Prerequisites

```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
```

## Documentation

| Document | Covers |
|---|---|
| [Store & Hooks](docs/guides/STORE_HOOKS.md) | Hook reference, composable hooks, patterns |
| [Concerns Guide](docs/guides/CONCERNS_GUIDE.md) | Concern lifecycle, built-ins, custom concerns |
| [Side Effects Guide](docs/SIDE_EFFECTS_GUIDE.md) | Sync, flip, aggregation, listener API |
| [WASM Architecture](docs/WASM_ARCHITECTURE.md) | JS/WASM boundary, data flow, ownership model |
| [Benchmark Comparison](docs/BENCHMARK_COMPARISON.md) | Detailed benchmark analysis across 16 scenarios |
| [Wildcard Paths](docs/WILD_FUNCTION_GUIDE.md) | `_()` hash key utility for Record types |
| [String Interpolation](docs/INTERPOLATION.md) | Template helpers for dynamic text concerns |
| [Testing Mock](docs/TESTING_MOCK.md) | Mock module for consumer tests (`vi.mock`) |
| [Debug Logging](docs/DEBUG_LOGGING.md) | Pipeline trace, console output, debug configuration |
| [Full Index](docs/README.md) | Complete documentation index |

## Roadmap

- **Aggregation modes** — Planned: `SUM`, `AVG`, `COUNT`, `MIN`, `MAX`, and custom reducer functions (currently consensus mode only).
- **Nested sub-stores** — Component-level state that participates in the parent's pipeline.

## License

MIT