Reactivity

Model state with refs, derive values with computed, and coordinate updates.

This page describes Regor reactivity based on actual runtime behavior.

StateDerived valuesObservers

Keep writable state small. Derive the rest with computed values.

Try it live

A quote that recalculates itselfLive Regor
YOUR TEAM / MONTHLY $100.00
Subtotal
$100.00
Discount
$0.00

Two inputs. Three computed values. One connected view.

import { batch, computed, html, ref } from 'regor'

export function createQuote(interactive = false) {
  const quantity = ref(4)
  const unitPrice = ref(25)
  const discounted = ref(false)
  const subtotal = computed(() => quantity() * unitPrice())
  const savings = computed(() => (discounted() ? subtotal() * 0.1 : 0))
  const total = computed(() => subtotal() - savings())
  const money = (value: number) => `$${value.toFixed(2)}`
  const reset = () =>
    batch(() => {
      quantity(4)
      unitPrice(25)
      discounted(false)
    })
  return {
    interactive,
    quantity,
    unitPrice,
    discounted,
    subtotal,
    savings,
    total,
    money,
    reset,
  }
}

export const quoteTemplate = html` <div class="guide-demo guide-demo--split">
  <div class="guide-controls">
    <label for="quote-quantity">Seats <strong>{{ quantity }}</strong></label>
    <input
      id="quote-quantity"
      type="range"
      min="1"
      max="20"
      r-model.number="quantity"
      :value="quantity"
      :disabled="!interactive"
    />
    <label for="quote-price"
      >Price per seat <strong>{{ money(unitPrice) }}</strong></label
    >
    <input
      id="quote-price"
      type="range"
      min="10"
      max="100"
      step="5"
      r-model.number="unitPrice"
      :value="unitPrice"
      :disabled="!interactive"
    />
    <label class="guide-check"
      ><input type="checkbox" r-model="discounted" :disabled="!interactive" />
      Apply a 10% team discount</label
    >
    <button type="button" @click="reset" :disabled="!interactive">
      Reset quote
    </button>
  </div>
  <div class="guide-readout">
    <span class="guide-kicker">YOUR TEAM / MONTHLY</span>
    <output class="guide-total" aria-live="polite">{{ money(total) }}</output>
    <dl>
      <div>
        <dt>Subtotal</dt>
        <dd>{{ money(subtotal) }}</dd>
      </div>
      <div>
        <dt>Discount</dt>
        <dd>{{ money(savings) }}</dd>
      </div>
    </dl>
    <p>Two inputs. Three computed values. One connected view.</p>
  </div>
</div>`
import { createApp, useScope } from 'regor'
import { createQuote, quoteTemplate } from './quote'
import { createServices, servicesTemplate } from './services'
import { createProfile, profileTemplate } from './profile'
import { createLifecycle, lifecycleTemplate } from './lifecycle'

// Each guide mounts only its own island. Everything outside the root stays static.
function mount<T extends object>(
  id: string,
  create: (interactive: boolean) => T,
  template: string,
) {
  const element = document.getElementById(id)
  if (element)
    createApp(
      useScope(() => create(true)),
      { element, template },
    )
}

mount('guide-quote', createQuote, quoteTemplate)
mount('guide-services', createServices, servicesTemplate)
mount('guide-profile', createProfile, profileTemplate)
mount('guide-lifecycle', createLifecycle, lifecycleTemplate)

Move either slider or apply the discount. The subtotal, savings, and total share the same reactive state.

Core model

  1. A ref is a callable value container (r(), r(newValue)) with .value alias.
  2. ref(), cref(), and sref() are all refs (isRef(...) === true).
  3. ref() is deep-conversion oriented.
  4. cref() is copy-first deep-conversion oriented.
  5. sref() is shallow-conversion oriented.

ref vs cref vs sref (important)

ref(value)

ref recursively converts nested object/array properties to refs.

const user = ref({ name: 'Ada', meta: { active: true } })
user().name('Grace')
user().meta().active(false)

Key behavior:

  1. Object/array content is converted recursively.
  2. Source objects are mutated in place during conversion.
  3. Existing refs are reused.
  4. Node, Date, RegExp, Promise, Error are not recursively converted.

cref(value)

cref first flattens the value into a plain copied structure, then recursively converts that copy to refs.

const source = { user: { name: 'Ada' } }
const user = cref(source)
user().user().name('Grace')
source.user.name // Ada

Key behavior:

  1. Equivalent to ref(flatten(value)).
  2. Source objects are not mutated during initial conversion.
  3. Nested refs are unwrapped into the copied structure before deep conversion.
  4. It costs more than ref, so prefer ref where in-place conversion is acceptable.

sref(value)

sref keeps nested properties as plain values (no recursive wrapping).

const user = sref({ name: 'Ada', age: 30 })
user().name = 'Grace'
user().age = 31

Key behavior:

  1. Nested values stay plain unless they were already refs.
  2. Arrays/Map/Set are made reactive via prototype adaptation.
  3. sref(sourceRef) returns the same ref instance.
ref() converts its input in place

Choose cref() when you need a copy, or sref() when you want to replace plain values yourself.

Access and update forms

For ref, cref, and sref:

const r = ref(1)
r() // get
r(2) // set
r.value // get
r.value = 3 // set

Derived refs: computed, computeRef, computeMany

Regor computed refs are:

  1. Read-only.
  2. Lazy (first evaluation happens on first read).
  3. Cached until dependencies change.
  4. Invalidated on source change, then recomputed on next read.
const a = ref(1)
const b = ref(2)
const sum = computeMany([a, b], (x, y) => x + y)

sum() // 3 (computes now)
sum() // 3 (cached)
a(5)
sum() // 7 (recomputed after invalidation)

watchEffect

watchEffect immediately runs and tracks refs accessed during execution.

When any tracked ref changes:

  1. Cleanup callbacks registered through onCleanup are called.
  2. Effect is re-subscribed using newly accessed refs.
const count = ref(0)
const stop = watchEffect((onCleanup) => {
  const snapshot = count()
  onCleanup?.(() => console.log('cleanup', snapshot))
  console.log('value', snapshot)
})

Use silence(() => ...) to read refs without tracking.

observe and observeMany

observe(source, cb, init?) listens to one ref. observeMany([a, b], cb, init?) listens to multiple refs and returns tuple values.

Both return a stop function.

Batch and manual trigger

batch/startBatch/endBatch defer observer notification until batch end.

batch(() => {
  a(1)
  a(2)
  b(3)
})

Observers are triggered once per changed ref after batch completion.

Use trigger(ref) to manually notify observers.

trigger(ref, undefined, true) recursively triggers nested refs.

Pause and resume

pause(ref) disables auto observer firing for that ref. resume(ref) re-enables it. Manual trigger(ref) still works while paused.

Other important helpers

  1. unref(x) unwraps one ref level.
  2. isRef(x) checks any ref (ref, cref, or sref).
  3. isDeepRef(x) checks whether value was created as deep ref.
  4. flatten(x) recursively unwraps refs in objects/arrays/Map/Set into plain data.
  5. entangle(a, b) creates two-way sync and initializes b with a.
  6. persist(ref, key) syncs ref data with localStorage.

flatten sample

Use flatten when you need a plain snapshot for logging, serialization, or transport.

const state = ref({
  user: ref({ name: 'Ada' }),
  tags: new Set([ref('core')]),
  meta: new Map([['count', ref(2)]]),
})

const plain = flatten(state)
// {
//   user: { name: 'Ada' },
//   tags: Set { 'core' },
//   meta: Map { 'count' => 2 }
// }

Collections and caveats

  1. Map and Set are reactive through Regor’s proxy prototypes.
  2. WeakMap and WeakSet are not auto-reactive; use trigger(...) manually when needed.
  3. For very large nested structures, pick ref, cref, or sref intentionally based on mutation policy, update style, and cost.

See Also

  1. API Reference
  2. Lifecycle and Cleanup
  3. Templates and Expressions