Skip to content

Reactive State

Sema Web's reactivity is built on @preact/signals-core. State is held in signals -- when a signal's value changes, anything that read it (components, computed values, watchers) automatically updates.

API Reference

(state val) -- Create Reactive State

Creates a new signal with the given initial value. Returns a signal reference (an opaque numeric ID).

sema
(def count (state 0))
(def name (state "Sema"))
(def items (state '(1 2 3)))
(def user (state {:name "Alice" :age 30}))

Signals can hold any Sema value: numbers, strings, lists, maps, booleans, nil.

@x -- Read Value

The @ reader macro dereferences a signal, returning its current value. Inside a component render function or computed expression, reading with @ automatically subscribes to changes.

sema
(def count (state 0))

@count          ;; => 0
(+ @count 1)    ;; => 1

@x expands to (deref x) at read time. You can use deref directly if you prefer:

sema
(deref count)   ;; same as @count

(put! x val) -- Set Value

Replaces the signal's value. Triggers re-renders for any subscribed components or computed values.

sema
(def count (state 0))
(put! count 42)
@count  ;; => 42

(def items (state '()))
(put! items '(1 2 3))

(update! x fn . args) -- Apply Function

Reads the current value, applies the function with the current value as the first argument (plus any additional args), and writes the result back.

sema
(def count (state 0))
(update! count (fn (n) (+ n 1)))   ;; count is now 1
(update! count (fn (n) (+ n 10)))  ;; count is now 11

;; With extra arguments
(def items (state '(1 2)))
(update! items (fn (lst x) (append lst (list x))) 3)
;; items is now (1 2 3)

update! is equivalent to (put! x (apply fn (cons @x args))).

(computed expr) -- Derived State

Creates a read-only signal whose value is computed from an expression. Dependencies are tracked automatically -- when any signal read inside the expression changes, the computed value updates.

sema
(def count (state 0))
(def doubled (computed (* @count 2)))
(def message (computed (string-append "Count is " (number->string @count))))

@doubled   ;; => 0
(put! count 5)
@doubled   ;; => 10
@message   ;; => "Count is 5"

computed is a macro. It wraps the expression in a thunk that the runtime calls inside @preact/signals-core's computed(), so dependency tracking works transparently.

WARNING

computed expressions should be pure -- no side effects, no put! calls. Use watch for side effects.

(batch body...) -- Coalesce Updates

Groups multiple state mutations into a single update pass. Without batch, each put! triggers an immediate re-render. With batch, re-renders are deferred until the batch completes.

sema
(def first-name (state ""))
(def last-name (state ""))

;; Without batch: two re-renders
(put! first-name "Ada")
(put! last-name "Lovelace")

;; With batch: one re-render
(batch
  (put! first-name "Ada")
  (put! last-name "Lovelace"))

Use batch when updating multiple related signals to avoid intermediate renders with inconsistent state.

(watch x fn) -- Side Effects

Observes a signal and calls a function whenever the value changes. The function receives the old and new values as arguments.

watch returns a numeric watch handle. Call unwatch! with that handle to stop observing.

sema
(def count (state 0))

(define (log-change old new)
  (console/log "count changed from" old "to" new))

(def stop-id (watch count log-change))

(put! count 1)   ;; logs: count changed from 0 to 1
(put! count 2)   ;; logs: count changed from 1 to 2

(unwatch! stop-id)

Common uses for watch:

  • Logging or analytics
  • Syncing to localStorage
  • Triggering network requests
  • Updating document title

TIP

Do not use watch to update other signals that drive rendering -- use computed instead. Watches are for effects outside the reactive graph (network, storage, logging).

Inside a component body

watch and computed belong at the top level of a module, in on-mount, or in an effect body -- somewhere that runs once. Calling either from a component's render body runs it again on every render, and neither has a name to memoize on the way local and resource do.

That is handled rather than left to accumulate, but the handling differs, and the difference is worth knowing:

  • watch is memoized per render site. One subscription is kept and each render swaps in its callback, so a watch registered from a render body fires once per change -- not once per render that ever ran. Registering one is reported through onerror once per component, because a reader who does not know it is memoized will read the single live registration as a bug.
  • computed is rebuilt. The previous render's derivation is disposed and a new one takes its place, so the value always reflects the render that read it. Memoizing instead would keep serving whatever the first closure produced until a tracked dependency happened to change -- stale data in place of a leak.

A render that stops registering a watch disposes it, and unmounting disposes everything either of them created.

sema
;; Fine: created once, disposed with the component.
(defcomponent view ()
  (on-mount (fn () (watch other on-other-change)))
  [:p @count])

(unwatch! watch-id) -- Stop Watching

Disposes a watch created by watch.

sema
(def watch-id (watch count log-change))
(unwatch! watch-id)

Complete Example: Todo List

sema
;; --- State ---
(def todos (state '()))
(def next-id (state 1))
(def filter-mode (state "all"))  ;; "all", "active", "done"

;; Derived state
(def visible-todos
  (computed
    (let ((mode @filter-mode)
          (all @todos))
      (cond
        ((equal? mode "active") (filter (fn (t) (not (get t :done))) all))
        ((equal? mode "done")   (filter (fn (t) (get t :done)) all))
        (else all)))))

(def active-count
  (computed (length (filter (fn (t) (not (get t :done))) @todos))))

;; --- Actions ---
(define (add-todo text)
  (batch
    (let ((id @next-id))
      (update! todos (fn (lst)
        (append lst (list {:id id :text text :done false}))))
      (update! next-id (fn (n) (+ n 1))))))

(define (toggle-todo id)
  (update! todos (fn (lst)
    (map (fn (t)
      (if (equal? (get t :id) id)
          (assoc t :done (not (get t :done)))
          t))
      lst))))

(define (remove-todo id)
  (update! todos (fn (lst)
    (filter (fn (t) (not (equal? (get t :id) id))) lst))))

;; Persist to localStorage
(watch todos (fn (old new)
  (store/set "todos" (json/encode new))))

Auto-tracking: How It Works

When a component renders or a computed expression evaluates, Sema Web runs the code inside a signals-core effect() or computed() context. Every @ (deref) call inside that context registers a dependency on the underlying signal.

sema
(def a (state 1))
(def b (state 2))

;; This computed depends on both `a` and `b`
(def sum (computed (+ @a @b)))

;; Updating either triggers recomputation
(put! a 10)   ;; sum becomes 12
(put! b 20)   ;; sum becomes 30

Dependencies are tracked dynamically, not statically. If a branch is not taken, those signals are not subscribed:

sema
(def show-detail (state false))
(def detail (state "..."))

(defcomponent view ()
  [:div
    (if @show-detail
        [:p @detail]       ;; only subscribes to `detail` when show-detail is true
        [:p "Summary"])])

Comparison with Other Frameworks

ConceptSema WebReactVue 3Solid
Create state(state 0)useState(0)ref(0)createSignal(0)
Read@countcountcount.valuecount()
Write(put! count 1)setCount(1)count.value = 1setCount(1)
Update(update! count inc)setCount(c => c+1)count.value++setCount(c => c+1)
Derived(computed expr)useMemo(fn, deps)computed(fn)createMemo(fn)
Batch(batch ...)Automatic in eventsnextTickbatch(fn)
Side effect(watch x fn)useEffectwatch(x, fn)createEffect
Local state(local "n" 0)useState(0)ref(0) in setupcreateSignal(0)

Key differences from React:

  • No dependency arrays. Auto-tracking means you never forget a dependency.
  • No stale closures. @count always reads the current value.
  • No hooks rules. local is keyed by name, not call order. Call it conditionally if you want.
  • Fine-grained updates. Only the specific DOM nodes that depend on a signal are patched, not the entire component subtree.

Common Patterns

Derived Filtered List

sema
(def items (state '(1 2 3 4 5 6 7 8 9 10)))
(def min-val (state 5))

(def filtered (computed (filter (fn (x) (>= x @min-val)) @items)))

@filtered  ;; => (5 6 7 8 9 10)
(put! min-val 8)
@filtered  ;; => (8 9 10)

Form State

sema
(def form-data (state {:name "" :email ""}))

(define (set-field field value)
  (update! form-data (fn (m) (assoc m field value))))

(define (handle-name-input ev)
  (set-field :name (dom/event-value ev)))

(define (handle-email-input ev)
  (set-field :email (dom/event-value ev)))

Undo/Redo

sema
(def history (state '()))
(def future (state '()))
(def current (state nil))

(define (push-state val)
  (batch
    (update! history (fn (h) (cons @current h)))
    (put! future '())
    (put! current val)))

(define (undo)
  (when (not (null? @history))
    (batch
      (update! future (fn (f) (cons @current f)))
      (put! current (car @history))
      (update! history cdr))))

Gotchas

Deref outside reactive context. @count works anywhere, but outside a component or computed, it just reads the value without subscribing. This is fine for event handlers and one-off reads.

Mutating nested structures. Signals track identity, not deep equality. To update a field in a map, you must put! or update! with a new map -- mutating the map in place will not trigger updates.

Computed must be synchronous. The expression inside computed runs synchronously. For async derived data, use watch to observe a signal and update another signal in the callback.