Skip to main content

TMListeners&FireModes

TableManager exposes changes through two surfaces that fire for the same underlying edits: path listeners registered at a specific path, and global Signals that fire for any path. This guide covers both, how their scheduling is controlled, and (for advanced cases) the metadata they carry.


Path listeners

Path listeners are registered at a path and return a Connection you can :Disconnect(). path may be a dot-string, an array, or a proxy.

Value listeners

  • OnValueChange(path, cb)cb(newValue, oldValue, metadata) — fires ONLY when path itself is directly reassigned.
  • OnChange(path, cb)cb(newValue, oldValue, metadata) — fires when path is directly reassigned OR any descendant of it changes.
  • Observe(path, cb) — immediately calls cb(currentValue, nil, nil), then behaves like OnValueChange.
local manager = TableManager.new({ Player = { Health = 100, Mana = 50 } })

manager:OnValueChange("Player", function()
	print("the Player table was replaced wholesale")
end)

manager:OnChange("Player", function()
	print("Player, or something under it, changed")
end)

manager:Set("Player.Health", 80)
-- only OnChange fires: Health changed, but Player itself was not reassigned

Key listeners (dictionaries)

  • OnKeyAdd(path, cb)cb(key, newValue, metadata) — a new key appears.
  • OnKeyRemove(path, cb)cb(key, oldValue, metadata) — a key is set to nil.
  • OnKeyChange(path, cb)cb(key, newValue, oldValue, metadata) — an existing key is reassigned.
local manager = TableManager.new({ Settings = { Volume = 50 } })

manager:OnKeyAdd("Settings", function(key, newValue)
	print(`new setting {key} = {newValue}`)
end)

manager:Set("Settings.Brightness", 80) -- "new setting Brightness = 80"

Array listeners

For array paths, path is the array's path (not the element path). Array events come from array-aware diffing, so indices are replay-faithful.

  • OnArrayInsert(path, cb)cb(index, newValue, metadata)
  • OnArrayRemove(path, cb)cb(index, oldValue, metadata)
  • OnArraySet(path, cb)cb(index, newValue, oldValue, metadata) — an existing slot overwritten in place (no shift).
local manager = TableManager.new({ Queue = { "a", "b", "c" } })

manager:OnArrayRemove("Queue", function(index, oldValue)
	print(`removed {oldValue} from index {index}`)
end)

manager:ArrayRemove("Queue", 1) -- "removed a from index 1"

Listener options

Every path listener accepts an optional ListenerOptions table:

  • ListenDepthnil (default) fires for the path or any depth below; 0 fires only AT the path; n fires up to n levels below.
  • ListenDepthStyle"<=" (default, at-or-within depth) or "==" (exactly that depth).
  • Once — auto-disconnect after the first fire. On a wildcard path this is once total across all matching keys, not once per key.

Controlling reach with ListenDepth

"Depth" is how many levels below the registered path a change happened. A change AT the path is depth 0; a change to one of its direct children is depth 1; a grandchild is depth 2, and so on. ListenDepth caps how far down the tree a change can be and still notify the listener.

local manager = TableManager.new({
	Game = { World = { Weather = { Rain = 0 } } },
})

Registering at "Game.World", the same edit reaches the listener differently depending on ListenDepth:

ListenDepth World replaced (depth 0) Weather replaced (depth 1) Rain changed (depth 2)
nil (default) fires fires fires
0 fires
1 fires fires
-- Only care about the World subtree's immediate shape, not deep leaf edits:
manager:OnChange("Game.World", function()
	print("World or one of its direct children changed")
end, { ListenDepth = 1 })

manager:Set("Game.World.Weather", {}) -- fires (depth 1)
manager:Set("Game.World.Weather.Rain", 5) -- does NOT fire (depth 2)

ListenDepthStyle decides whether the number is a ceiling or an exact match. "<=" (default) fires at-or-within the depth; "==" fires ONLY for changes at exactly that depth — e.g. ListenDepth = 1, ListenDepthStyle = "==" fires for a direct child but not for the path itself or a grandchild.

This is also what separates the two value listeners: OnChange is an unbounded ListenDepth = nil, while OnValueChange is exactly ListenDepth = 0, ListenDepthStyle = "==" — fire only when the path itself is reassigned.

Depth is measured from OriginPath

The level compared against ListenDepth is #metadata.OriginPath - #registeredPath (clamped at ≥ 0) — the distance from your listener's path down to where the assignment actually happened.

Wildcards

A "*" segment matches any key at that position, so one listener covers a dynamic collection without re-registering on KeyAdded. The matched keys arrive on metadata.WildcardMatches (left-to-right, one entry per "*").

manager:OnValueChange("Players.*.Health", function(new, old, metadata)
	print(`{metadata.WildcardMatches[1]} health: {old} -> {new}`)
end)

The same grammar works for writes and bulk reads. See the dedicated TM Wildcards guide for the full rules (multiple wildcards, Set/Update/Increment fan-out, GetMatching, and the Once-fires-once-total caveat).


Fire modes

Two independent settings control how callbacks are scheduled when they fire. Both accept "immediate", "deferred", "bindable", or "coalesced":

"immediate" runs the callback synchronously; "deferred" runs it via task.defer; "bindable" mirrors the engine's actual SignalBehavior (resolved once at construction); "coalesced" is like "deferred" but collapses repeated fires within the tick into one carrying the latest values.

Ordering is preserved regardless of mode: a Signal fires before registry listeners at each emission point, ancestor delivery walks parent→root, and a write made from inside a listener has its events delivered after the current dispatch completes.

Changing the defaults

Set the modes per-manager in the config, or change the process-wide defaults with TableManager.SetDefaults:

-- Per manager:
local manager = TableManager.new(data, { SignalFireMode = "coalesced" })

-- Process-wide default for every manager created afterwards:
TableManager.SetDefaults({ ListenerFireMode = "deferred" })
Defaults are read at construction time

SetDefaults only affects TableManagers created after the call — existing instances keep the mode they were built with.


Listener metadata (power users)

Optional

Every listener callback's last argument is a ChangeMetadata table, but most code never needs to inspect it beyond a nil check (e.g. Observe's initial fire, or wildcard matches — see above). This section is for the cases where you need finer control.

  • Diff — a DiffNode for a direct change at the listener's path; nil for an ancestor notification. This nil-vs-present split is how a listener tells its own change from a descendant's.
  • OriginPath — where the assignment actually happened (same for direct and ancestor notifications).
  • WildcardMatches — the matched wildcard keys; nil when the path had no wildcards.
  • Move / ArrayOp — set on array-op events to carry shift semantics.
manager:OnValueChange("Game.World", function(_, _, metadata)
	if metadata.Diff then
		print("directly replaced at Game.World")
	else
		print("descendant changed, originating at", table.concat(metadata.OriginPath, "."))
	end
end)

Global Signals

Every manager also exposes Signals that fire for any path, carrying the path as their first argument:

ValueChanged(path, newValue, oldValue), KeyAdded(path, key, newValue), KeyRemoved(path, key, oldValue), KeyChanged(path, key, newValue, oldValue), ArrayInserted(path, index, newValue), ArrayRemoved(path, index, oldValue), ArraySet(path, index, newValue, oldValue).

manager.ValueChanged:Connect(function(path, newValue, oldValue)
	print(`{table.concat(path, ".")}: {oldValue} -> {newValue}`)
end)

See also

Show raw api
{
    "functions": [],
    "properties": [],
    "types": [],
    "name": "TM Listeners & Fire Modes",
    "desc": "[TableManager](/api/TableManager) exposes changes through two surfaces that\nfire for the same underlying edits: **path listeners** registered at a specific\npath, and **global Signals** that fire for any path. This guide covers both, how\ntheir scheduling is controlled, and (for advanced cases) the metadata they carry.\n\n---\n## Path listeners\n\nPath listeners are registered at a path and return a `Connection` you can\n`:Disconnect()`. `path` may be a dot-string, an array, or a proxy.\n\n### Value listeners\n\n- `OnValueChange(path, cb)` → `cb(newValue, oldValue, metadata)` — fires ONLY\n  when `path` itself is directly reassigned.\n- `OnChange(path, cb)` → `cb(newValue, oldValue, metadata)` — fires when `path`\n  is directly reassigned OR any descendant of it changes.\n- `Observe(path, cb)` — immediately calls `cb(currentValue, nil, nil)`, then\n  behaves like `OnValueChange`.\n\n```lua\nlocal manager = TableManager.new({ Player = { Health = 100, Mana = 50 } })\n\nmanager:OnValueChange(\"Player\", function()\n\tprint(\"the Player table was replaced wholesale\")\nend)\n\nmanager:OnChange(\"Player\", function()\n\tprint(\"Player, or something under it, changed\")\nend)\n\nmanager:Set(\"Player.Health\", 80)\n-- only OnChange fires: Health changed, but Player itself was not reassigned\n```\n\n### Key listeners (dictionaries)\n\n- `OnKeyAdd(path, cb)` → `cb(key, newValue, metadata)` — a new key appears.\n- `OnKeyRemove(path, cb)` → `cb(key, oldValue, metadata)` — a key is set to `nil`.\n- `OnKeyChange(path, cb)` → `cb(key, newValue, oldValue, metadata)` — an\n  existing key is reassigned.\n\n```lua\nlocal manager = TableManager.new({ Settings = { Volume = 50 } })\n\nmanager:OnKeyAdd(\"Settings\", function(key, newValue)\n\tprint(`new setting {key} = {newValue}`)\nend)\n\nmanager:Set(\"Settings.Brightness\", 80) -- \"new setting Brightness = 80\"\n```\n\n### Array listeners\n\nFor array paths, `path` is the array's path (not the element path). Array\nevents come from array-aware diffing, so indices are replay-faithful.\n\n- `OnArrayInsert(path, cb)` → `cb(index, newValue, metadata)`\n- `OnArrayRemove(path, cb)` → `cb(index, oldValue, metadata)`\n- `OnArraySet(path, cb)` → `cb(index, newValue, oldValue, metadata)` — an\n  existing slot overwritten in place (no shift).\n\n```lua\nlocal manager = TableManager.new({ Queue = { \"a\", \"b\", \"c\" } })\n\nmanager:OnArrayRemove(\"Queue\", function(index, oldValue)\n\tprint(`removed {oldValue} from index {index}`)\nend)\n\nmanager:ArrayRemove(\"Queue\", 1) -- \"removed a from index 1\"\n```\n\n---\n## Listener options\n\nEvery path listener accepts an optional `ListenerOptions` table:\n\n- `ListenDepth` — `nil` (default) fires for the path or any depth below; `0`\n  fires only AT the path; `n` fires up to `n` levels below.\n- `ListenDepthStyle` — `\"<=\"` (default, at-or-within depth) or `\"==\"` (exactly\n  that depth).\n- `Once` — auto-disconnect after the first fire. On a wildcard path this is\n  once **total** across all matching keys, not once per key.\n\n### Controlling reach with ListenDepth\n\n\"Depth\" is how many levels *below* the registered path a change happened. A\nchange AT the path is depth `0`; a change to one of its direct children is\ndepth `1`; a grandchild is depth `2`, and so on. `ListenDepth` caps how far\ndown the tree a change can be and still notify the listener.\n\n```lua\nlocal manager = TableManager.new({\n\tGame = { World = { Weather = { Rain = 0 } } },\n})\n```\n\nRegistering at `\"Game.World\"`, the same edit reaches the listener differently\ndepending on `ListenDepth`:\n\n| `ListenDepth` | `World` replaced (depth 0) | `Weather` replaced (depth 1) | `Rain` changed (depth 2) |\n| --- | --- | --- | --- |\n| `nil` (default) | fires | fires | fires |\n| `0` | fires | — | — |\n| `1` | fires | fires | — |\n\n```lua\n-- Only care about the World subtree's immediate shape, not deep leaf edits:\nmanager:OnChange(\"Game.World\", function()\n\tprint(\"World or one of its direct children changed\")\nend, { ListenDepth = 1 })\n\nmanager:Set(\"Game.World.Weather\", {}) -- fires (depth 1)\nmanager:Set(\"Game.World.Weather.Rain\", 5) -- does NOT fire (depth 2)\n```\n\n`ListenDepthStyle` decides whether the number is a ceiling or an exact match.\n`\"<=\"` (default) fires at-or-within the depth; `\"==\"` fires ONLY for changes\nat exactly that depth — e.g. `ListenDepth = 1, ListenDepthStyle = \"==\"` fires\nfor a direct child but not for the path itself or a grandchild.\n\nThis is also what separates the two value listeners: `OnChange` is an\nunbounded `ListenDepth = nil`, while `OnValueChange` is exactly\n`ListenDepth = 0, ListenDepthStyle = \"==\"` — fire only when the path itself is\nreassigned.\n\n:::info Depth is measured from `OriginPath`\nThe level compared against `ListenDepth` is\n`#metadata.OriginPath - #registeredPath` (clamped at ≥ 0) — the distance from\nyour listener's path down to where the assignment actually happened.\n:::\n\n### Wildcards\n\nA `\"*\"` segment matches any key at that position, so one listener covers a\ndynamic collection without re-registering on `KeyAdded`. The matched keys\narrive on `metadata.WildcardMatches` (left-to-right, one entry per `\"*\"`).\n\n```lua\nmanager:OnValueChange(\"Players.*.Health\", function(new, old, metadata)\n\tprint(`{metadata.WildcardMatches[1]} health: {old} -> {new}`)\nend)\n```\n\nThe same grammar works for writes and bulk reads. See the dedicated\n**[TM Wildcards](/api/TM%20Wildcards)** guide for the full rules (multiple\nwildcards, `Set`/`Update`/`Increment` fan-out, `GetMatching`, and the\n`Once`-fires-once-total caveat).\n\n---\n## Fire modes\n\nTwo independent settings control *how* callbacks are scheduled when they fire.\nBoth accept `\"immediate\"`, `\"deferred\"`, `\"bindable\"`, or `\"coalesced\"`:\n\n- **[ListenerFireMode](/api/TableManager#ListenerFireMode)** — path listeners.\n- **[SignalFireMode](/api/TableManager#SignalFireMode)** — the global Signals.\n\n`\"immediate\"` runs the callback synchronously; `\"deferred\"` runs it via\n`task.defer`; `\"bindable\"` mirrors the engine's actual `SignalBehavior`\n(resolved once at construction); `\"coalesced\"` is like `\"deferred\"` but\ncollapses repeated fires within the tick into one carrying the latest values.\n\nOrdering is preserved regardless of mode: a Signal fires before registry\nlisteners at each emission point, ancestor delivery walks parent→root, and a\nwrite made from inside a listener has its events delivered after the current\ndispatch completes.\n\n### Changing the defaults\n\nSet the modes per-manager in the config, or change the process-wide defaults\nwith [`TableManager.SetDefaults`](/api/TableManager#SetDefaults):\n\n```lua\n-- Per manager:\nlocal manager = TableManager.new(data, { SignalFireMode = \"coalesced\" })\n\n-- Process-wide default for every manager created afterwards:\nTableManager.SetDefaults({ ListenerFireMode = \"deferred\" })\n```\n\n:::caution Defaults are read at construction time\n`SetDefaults` only affects TableManagers created **after** the call — existing\ninstances keep the mode they were built with.\n:::\n\n---\n## Listener metadata (power users)\n\n:::info Optional\nEvery listener callback's last argument is a `ChangeMetadata` table, but most\ncode never needs to inspect it beyond a `nil` check (e.g. `Observe`'s initial\nfire, or wildcard matches — see above). This section is for the cases where\nyou need finer control.\n:::\n\n- `Diff` — a `DiffNode` for a **direct** change at the listener's path; `nil`\n  for an ancestor notification. This nil-vs-present split is how a listener\n  tells its own change from a descendant's.\n- `OriginPath` — where the assignment actually happened (same for direct and\n  ancestor notifications).\n- `WildcardMatches` — the matched wildcard keys; `nil` when the path had no wildcards.\n- `Move` / `ArrayOp` — set on array-op events to carry shift semantics.\n\n```lua\nmanager:OnValueChange(\"Game.World\", function(_, _, metadata)\n\tif metadata.Diff then\n\t\tprint(\"directly replaced at Game.World\")\n\telse\n\t\tprint(\"descendant changed, originating at\", table.concat(metadata.OriginPath, \".\"))\n\tend\nend)\n```\n\n---\n## Global Signals\n\nEvery manager also exposes Signals that fire for **any** path, carrying the\npath as their first argument:\n\n`ValueChanged(path, newValue, oldValue)`, `KeyAdded(path, key, newValue)`,\n`KeyRemoved(path, key, oldValue)`, `KeyChanged(path, key, newValue, oldValue)`,\n`ArrayInserted(path, index, newValue)`, `ArrayRemoved(path, index, oldValue)`,\n`ArraySet(path, index, newValue, oldValue)`.\n\n```lua\nmanager.ValueChanged:Connect(function(path, newValue, oldValue)\n\tprint(`{table.concat(path, \".\")}: {oldValue} -> {newValue}`)\nend)\n```\n\n---\n### See also\n\n- **[TM Getting Started](/api/TM%20Getting%20Started)** — creating a manager and your first listener.\n- **[TM Wildcards](/api/TM%20Wildcards)** — `\"*\"` paths for listeners, writes, and bulk reads.\n- **[TM Flushing](/api/TM%20Flushing)** — the diff cycle that decides when these callbacks run.\n- **[TM Batching](/api/TM%20Batching)** — grouping writes so events fire once.\n- **[TM For & Map Reactive Views](/api/TM%20For%20&%20Map%20Reactive%20Views)** — per-item reconcilers built on these listeners.",
    "source": {
        "line": 244,
        "path": "lib/tablemanager/src/Docs/TM_Listeners_And_Fire_Modes.luau"
    }
}