Mutable state defaults
Hibiki::Reactive’s state macro accepts a default value in two forms:
state :count, 0 # positional default
state(:history) { [] } # block default
They differ in when the default is evaluated, and for mutable values (arrays, hashes, mutable strings, your own objects) that difference is a correctness issue, not a style choice.
The positional form is one shared object
A positional default is evaluated once, when the class body runs — the macro captures that single object in a closure. Every instance’s lazily created signal is then initialized with a reference to the same object:
class Feed
include Hibiki::Reactive
state :entries, [] # ONE array, shared by every Feed
end
a = Feed.new
b = Feed.new
a.entries << "hello"
b.entries # => ["hello"] — b sees a's data
This is the same trap as Rails attribute defaults (attribute :tags,
default: [] shares one array across all records, which is why the Rails
idiom is default: -> { [] }), and the same reason Hash.new([]) is a
classic Ruby footgun.
In-place mutation also bypasses reactivity
The shared object is only half the problem. Mutating a value in place
(<<, push, merge!, sub!) never goes through the signal’s writer, so
no subscribers are notified — deriveds stay cached and effects don’t
run. Assigning the mutated object back doesn’t help either:
self.entries = entries << "hello"
entries << "hello" mutates the array and returns the same object, so
the write sees a value == to the current one and is discarded as a no-op
(writing an equal value never notifies — a core invariant). The mutation
happens; the graph never hears about it.
The correct update pattern for mutable state is to assign a fresh object:
self.entries += ["hello"] # new array
self.entries = entries.merge(key: value) # new hash (Hash#merge, not merge!)
The block form: fresh default per instance
state(:history) { [] }
The block is stored at class-definition time but called per instance, at the moment the signal is first touched. Each instance gets its own fresh object. Two details of how it runs:
- It is
instance_exec‘d, so it can reference the instance’s other attributes and methods. - It runs untracked: the first touch may happen inside some effect’s tracking window, and a default that reads other signals must not subscribe that outer observer.
When the positional form is fine
Sharing an object nobody can mutate is harmless. Numbers, symbols, nil,
true/false, frozen strings (any literal under
# frozen_string_literal: true), and other frozen or immutable values are
all safe as positional defaults:
state :count, 0 # fine
state :label, "untitled" # fine (frozen string literal)
state(:tags) { [] } # block form — it's mutable
Rule of thumb: if the default is mutable, use the block form — and update it by assigning a new object, never by mutating in place.