Skip to content

Readonly and Shallow Proxies

Besides reactive(), observ offers three variants that control who may write to state and how deep the reactivity goes.

Readonly proxies

readonly() returns a proxy through which the state can be read (with full dependency tracking) but not modified. Any attempt to write through it raises a ReadonlyError:

from observ import reactive, readonly

state = reactive({"count": 0})
view = readonly(state)

print(view["count"])  # reads work fine, and are tracked

view["count"] = 1     # raises ReadonlyError

The readonly proxy wraps the same underlying data: changes made through the writable proxy are visible through the readonly proxy and trigger its watchers.

This enables a pattern where one part of your application owns the state and hands out a readonly view to the rest:

class Store:
    def __init__(self, initial_state):
        self._state = reactive(initial_state)
        self.state = readonly(self._state)

    def increment(self):
        self._state["count"] += 1

Consumers can watch store.state freely but can only modify it through the store's methods, keeping mutations centralized and predictable. (This is essentially what reliev provides, including undo/redo support.)

Shallow proxies

By default, reactivity is deep: accessing a nested container through a proxy yields a proxy for that container as well. shallow_reactive() limits the reactivity to the first level of the wrapped object — nested values are returned raw:

from observ import shallow_reactive

state = shallow_reactive({"big": {"huge": [...]}})

state["big"] = other      # tracked: first-level write
state["big"]["huge"] = x  # NOT tracked: state["big"] is a plain dict

This is an escape hatch for performance-sensitive cases: when a value is a large data structure that you replace wholesale rather than mutate in place, shallow reactivity avoids the overhead of proxying every nested access.

shallow_readonly() combines both behaviors: a read-only view where only first-level reads are tracked.

Force-triggering with trigger_ref

Sometimes you do mutate a value nested inside a shallow proxy in place — for example when the structure is too large to replace wholesale. Since those mutations bypass the proxy, observ cannot see them. trigger_ref() (named after Vue's triggerRef) force-notifies the watchers that depend on a proxy, as if its first level was written to:

from observ import shallow_reactive, trigger_ref, watch_effect

state = shallow_reactive({"big": {"huge": [...]}})

watcher = watch_effect(lambda: render(state["big"]))

state["big"]["huge"].append(item)  # NOT tracked: deep mutation
trigger_ref(state)                 # force: re-runs the effect

Watchers re-evaluate their watched function; whether a watch() callback then fires follows the normal rules: watchers on a container value (or with deep=True) always fire, while a watcher on a plain value only fires when that value actually differs from the previous evaluation.

trigger_ref accepts any proxy (it works on the result of ref() and reactive() too). All proxies for the same target share their bookkeeping, so triggering one view notifies the watchers on all of them.

Overview

Function Writable Deep
reactive
readonly
shallow_reactive
shallow_readonly

All four share the same underlying target and bookkeeping: you can create any combination of views for the same data, and writes through a writable view notify watchers on all views.