Skip to main content

TMReactiveStateWrappers

TableManager is pure Luau and intentionally ships no binding to any UI or state-management framework. When you want managed data to drive a reactive system — a Fusion Value, a Vide source, a React hook — you write a small adapter over the listener API and keep it in your own application code.

This guide shows patterns for a reusable one-way bind for common reactive libraries. These are example functions you write, not methods the package provides.

Choosing when the state re-sets

You may want to pass through options to the listener so the wrapper re-sets only on certain changes.

  • default (no options) — re-set whenever the path or any descendant changes. Right when the external value mirrors a whole table/subtree and the UI must react to edits inside it (bind "Player", re-set when Player.Health changes). The re-set hands the framework the same table reference, so lean on the framework's own equality/immutability rules.
  • { ListenDepth = 0, ListenDepthStyle = "==" } — the OnValueChange behavior: re-set only when the bound path itself is reassigned wholesale. Right for leaf values, or when only a subtree's identity matters.
  • { ListenDepth = n } — the middle ground: re-set only for changes down to n levels below the path. Mirror a subtree but ignore deep-leaf churn.
Build on OnChange, not OnValueChange

OnValueChange overwrites ListenDepth / ListenDepthStyle internally, so passing depth options to it does nothing. Subscribe with OnChange so options takes effect. See the depth rules in TM Listeners & Fire Modes.

ToFusionState

Mirror a manager path into a Fusion Value owned by a scope. The binding registers its own teardown with the scope, so scope:doCleanup() drops it. I recommend returning as a State (the Value's type) rather than the Value itself, so callers arent tempted to call set on it directly.

local Fusion = require(Packages.Fusion)
local Value = Fusion.Value
local peek = Fusion.peek

local function ToFusionState(scope, manager, path, options)
	local state = Value(scope, manager:Get(path))
	local connection = manager:OnChange(path, function(newValue)
		state:set(newValue)
	end, options)
	table.insert(scope, function()
		connection:Disconnect()
	end)
	return state
end

-- Usage:
local health = ToFusionState(scope, manager, "Player.Health")
print(peek(health)) -- 100
manager:Set("Player.Health", 80) -- `health` updates to 80

Allowing wrappers to take the same optional options, so end-users can tune when they re-set:

scope.ToFusionState = ToFusionState

-- Re-set on any change under "Player" (health, mana, nested gear...):
local player = scope:ToFusionState(manager, "Player")

-- Re-set only when "Player" is swapped wholesale (OnValueChange behavior):
local player = scope:ToFusionState(manager, "Player",
	{ ListenDepth = 0, ListenDepthStyle = "==" })

-- Re-set for "Player" and its direct children, ignore deeper churn:
local player = scope:ToFusionState(manager, "Player", { ListenDepth = 1 })

ToVideSource

The Vide equivalent, using a source and vide.cleanup for teardown.

local vide = require(Packages.Vide)
local source = vide.source
local cleanup = vide.cleanup

-- Call this within a reactive scope (root / mount / effect / component) so
-- cleanup() has a scope to attach to. Outside one, disconnect the returned
-- Connection yourself.
local function ToVideSource(manager, path, options)
	local src = source(manager:Get(path))
	local connection = manager:OnChange(path, function(newValue)
		src(newValue)
	end, options)
	cleanup(connection)
	return src
end

-- Usage:
local health = ToVideSource(manager, "Player.Health")
print(health()) -- 100
manager:Set("Player.Health", 80)
print(health()) -- 80

Cleanup

Both wrappers self-register teardown with their scope, so in the common case you do nothing — scope:doCleanup() (Fusion) or the reactive scope's destruction (Vide) disconnects the binding. Destroying the manager (manager:Destroy()) also disconnects everything.


See also

Show raw api
{
    "functions": [],
    "properties": [],
    "types": [],
    "name": "TM Reactive State Wrappers",
    "desc": "[TableManager](/api/TableManager) is pure Luau and intentionally ships **no**\nbinding to any UI or state-management framework. When you want managed data to\ndrive a reactive system — a [Fusion](https://elttob.uk/Fusion/) `Value`, a\n[Vide](https://centau.github.io/vide/) `source`, a React hook — you write a\nsmall adapter over the listener API and keep it in your own application code.\n\nThis guide shows patterns for a reusable one-way bind for common reactive libraries.\nThese are **example functions you write**, not methods the package provides.\n\n## Choosing when the state re-sets\n\nYou may want to pass through `options` to the listener so the wrapper re-sets only \non certain changes.\n\n- **default (no options)** — re-set whenever the path **or any descendant**\n  changes. Right when the external value mirrors a whole table/subtree and the\n  UI must react to edits *inside* it (bind `\"Player\"`, re-set when\n  `Player.Health` changes). The re-set hands the framework the same table\n  reference, so lean on the framework's own equality/immutability rules.\n- **`{ ListenDepth = 0, ListenDepthStyle = \"==\" }`** — the `OnValueChange`\n  behavior: re-set only when the bound path itself is reassigned wholesale.\n  Right for leaf values, or when only a subtree's identity matters.\n- **`{ ListenDepth = n }`** — the middle ground: re-set only for changes down to\n  `n` levels below the path. Mirror a subtree but ignore deep-leaf churn.\n\n:::caution Build on `OnChange`, not `OnValueChange`\n`OnValueChange` **overwrites** `ListenDepth` / `ListenDepthStyle` internally, so\npassing depth options to it does nothing. Subscribe with `OnChange` so `options`\ntakes effect. See the depth rules in\n**[TM Listeners & Fire Modes](/api/TM%20Listeners%20&%20Fire%20Modes)**.\n:::\n\n## ToFusionState\n\nMirror a manager path into a Fusion `Value` owned by a `scope`. The binding\nregisters its own teardown with the scope, so `scope:doCleanup()` drops it.\nI recommend returning as a `State` (the `Value`'s type) rather than the `Value`\nitself, so callers arent tempted to call `set` on it directly.\n\n```lua\nlocal Fusion = require(Packages.Fusion)\nlocal Value = Fusion.Value\nlocal peek = Fusion.peek\n\nlocal function ToFusionState(scope, manager, path, options)\n\tlocal state = Value(scope, manager:Get(path))\n\tlocal connection = manager:OnChange(path, function(newValue)\n\t\tstate:set(newValue)\n\tend, options)\n\ttable.insert(scope, function()\n\t\tconnection:Disconnect()\n\tend)\n\treturn state\nend\n\n-- Usage:\nlocal health = ToFusionState(scope, manager, \"Player.Health\")\nprint(peek(health)) -- 100\nmanager:Set(\"Player.Health\", 80) -- `health` updates to 80\n```\n\nAllowing wrappers to take the same optional `options`, so end-users can \ntune *when* they re-set:\n\n```lua\nscope.ToFusionState = ToFusionState\n\n-- Re-set on any change under \"Player\" (health, mana, nested gear...):\nlocal player = scope:ToFusionState(manager, \"Player\")\n\n-- Re-set only when \"Player\" is swapped wholesale (OnValueChange behavior):\nlocal player = scope:ToFusionState(manager, \"Player\",\n\t{ ListenDepth = 0, ListenDepthStyle = \"==\" })\n\n-- Re-set for \"Player\" and its direct children, ignore deeper churn:\nlocal player = scope:ToFusionState(manager, \"Player\", { ListenDepth = 1 })\n```\n\n## ToVideSource\n\nThe Vide equivalent, using a `source` and `vide.cleanup` for teardown.\n\n```lua\nlocal vide = require(Packages.Vide)\nlocal source = vide.source\nlocal cleanup = vide.cleanup\n\n-- Call this within a reactive scope (root / mount / effect / component) so\n-- cleanup() has a scope to attach to. Outside one, disconnect the returned\n-- Connection yourself.\nlocal function ToVideSource(manager, path, options)\n\tlocal src = source(manager:Get(path))\n\tlocal connection = manager:OnChange(path, function(newValue)\n\t\tsrc(newValue)\n\tend, options)\n\tcleanup(connection)\n\treturn src\nend\n\n-- Usage:\nlocal health = ToVideSource(manager, \"Player.Health\")\nprint(health()) -- 100\nmanager:Set(\"Player.Health\", 80)\nprint(health()) -- 80\n```\n\n## Cleanup\n\nBoth wrappers self-register teardown with their scope, so in the common case you\ndo nothing — `scope:doCleanup()` (Fusion) or the reactive scope's destruction\n(Vide) disconnects the binding. Destroying the manager\n([`manager:Destroy()`](/api/TableManager#Destroy)) also disconnects everything.\n\n---\n### See also\n\n- **[TM Listeners & Fire Modes](/api/TM%20Listeners%20&%20Fire%20Modes)** — the listeners these wrappers build on, and the full `ListenDepth` rules.\n- **[TM For & Map Reactive Views](/api/TM%20For%20&%20Map%20Reactive%20Views)** — built-in reactive views for collections, with automatic per-item cleanup.\n- **[TM Getting Started](/api/TM%20Getting%20Started)** — creating a manager and reading its data.",
    "source": {
        "line": 124,
        "path": "lib/tablemanager/src/Docs/TM_Reactive_State_Wrappers.luau"
    }
}