🍀 Atomic state management for Roblox https://npmjs.org/package/@rbxts/charm
Find a file
2025-11-02 18:55:41 -08:00
.github/workflows ci: update test file path 2025-07-02 09:28:38 -07:00
assets refactor: improve logic 2025-05-12 02:15:49 -07:00
benches feat: add signal api 2025-10-31 00:36:55 -07:00
bin refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
packages refactor: remove unnecessary features, update docs 2025-11-02 18:55:41 -08:00
test feat: add signal api 2025-10-31 00:36:55 -07:00
.gitignore feat: add flag for inner effect tracking 2025-10-02 19:37:03 -07:00
.gitmodules chore: housekeeping 2025-05-21 12:16:29 -07:00
.luaurc refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
.npmrc chore: housekeeping 2025-05-21 12:16:29 -07:00
.prettierrc refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
build.project.json refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
default.project.json refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
eslint.config.ts chore: update eslint, housekeeping 2025-09-25 15:17:08 -07:00
LICENSE.md feat: initial commit 2024-02-25 03:41:09 -08:00
package.json chore: update eslint, housekeeping 2025-09-25 15:17:08 -07:00
pnpm-lock.yaml chore: update eslint, housekeeping 2025-09-25 15:17:08 -07:00
pnpm-workspace.yaml refactor: improve structure and alien-signals integration 2025-07-02 09:25:50 -07:00
README.md refactor: remove unnecessary features, update docs 2025-11-02 18:55:41 -08:00
rokit.toml feat: update alien-signals 2025-10-31 23:06:43 -07:00
selene.toml refactor: rewrite untracked, update alien-signals 2025-09-23 16:50:39 -07:00
selene_defs.yml refactor: add assertion error messages 2025-05-06 11:18:19 -07:00
stylua.toml feat: use alien-signals 2025-05-03 18:24:39 -07:00
tsconfig.json chore: update eslint, housekeeping 2025-09-25 15:17:08 -07:00

Logo

Charm

Manage state with reactive signals
npm package →

GitHub Workflow Status NPM Version GitHub License

Charm is a state management library that aims to bring reactive signals from libraries like Preact and Solid to Roblox. It's built on the alien-signals signal algorithm, expanding upon it further to handle a wide range of use cases, from rendering user interfaces to handling server game logic.

Charm has a few core principles:

  • Manage state with reactive signals: state containers that hold a value
  • React to state updates: reading a signal automatically subscribes to it
  • Combine multiple signals: derive a new value from existing state that stays up-to-date
  • Fine-grained reactivity: make targeted updates in response to specific changes in the state
  • Use immutable data: values are compared directly (==) to optimize change detection

Want to learn more about signals?

At a Glance

local getTodos, setTodos = signal({} :: { [number]: string })
local getQuery, setQuery = signal("")

observe(getTodos, function(todo, index)
	local instance = Instance.new("TextLabel")

	local getText = computed(function(prevText)
		return getTodos()[index] or prevText
	end)

	effect(function()
		instance.Text = getText()
		instance.Visible = getText():match(getQuery()) ~= nil
	end)

	instance.LayoutOrder = index
	instance.Size = UDim2.new(1, 0, 0, 40)
	instance.Parent = screenGui

	return function()
		instance:Destroy()
	end
end)

setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
setQuery("^Buy")
Explain code
local getTodos, setTodos = signal({} :: { [number]: string })
local getQuery, setQuery = signal("")

-- Create a text label when an item is added to the list
observe(getTodos, function(todo, index)
	local instance = Instance.new("TextLabel")

	-- Create a computed signal that stores the current text, or the previous
	-- value if the item was removed
	local getText = computed(function(prevText)
		return getTodos()[index] or prevText
	end)

	-- Set properties when the text or query updates
	effect(function()
		instance.Text = getText()
		instance.Visible = getText():match(getQuery()) ~= nil
	end)

	instance.LayoutOrder = index
	instance.Size = UDim2.new(1, 0, 0, 40)
	instance.Parent = screenGui

	-- Destroy the instance when this item is removed
	return function()
		instance:Destroy()
	end
end)

-- Add items to the todo list
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
-- Filter for items starting with "Buy"
setQuery("^Buy")

Installation

npm install @rbxts/charm
yarn add @rbxts/charm
pnpm add @rbxts/charm
[dependencies]
Charm = "littensy/charm@VERSION"

Guide

signal(initialValue, equals?)

Signals are the core of reactivity in Charm. The signal function creates a reactive signal that acts as a container for a value. It returns a function to access the value, and another to update the value.

local getCounter, setCounter = signal(0)

print(getCounter()) --> 0
setCounter(1)
setCounter(function(count)
	return count + 1
end)

Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately update every effect and computed signal that depends signal, ensuring every part of your state is correct and up-to-date.

You can also pass a custom equality function to only update the signal if the new value is not equal to the current value:

local getMax, setMax = signal(0, function(incoming, current)
	return incoming <= current
end)

setMax(1) --> 1
setMax(-1) --> 1

Note

Looking for atoms? You can still use atom() to create a signal with a combined getter and setter. To use signals for APIs that require atoms, use signalToAtom().


computed(getter)

The computed function creates a new signal with a value derived from multiple signals. The computed signal can be reacted to like a normal signal, and it returns the getter function's last return value.

The computed signal will subscribe to signals accessed by the getter and track them as dependencies. Changes to these dependencies will re-execute the getter, and if it returns a new value, then the computed signal is updated to that value.

local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe")
local getFullName = computed(function()
	return `{getName()} {getSurname()}`
end)

print(getFullName()) --> "John Doe"
setName("Jane")
print(getFullName()) --> "Jane Doe"

The getter function also receives the previous state (or nil if running for the first time). You can use this for more complex state:

local getCounter, setCounter = signal(10)
local getMax = computed(function(prevMax)
	return math.max(getCounter(), prevMax or 0)
end)

print(getMax()) --> 10
setCounter(5)
print(getMax()) --> 10

Computed signals are updated lazily, meaning the getter will only re-execute once the computed signal is called and if a dependency updated since the last call.


effect(callback)

Effects are fundamental to reactivity, allowing you to react to signal updates. The effect function subscribes to signals accessed by the effect callback, and when a dependency updates, the callback will re-execute.

local getCounter, setCounter = signal(0)

effect(function()
	print(`Count is {getCounter()}`)
end) --> Count is 0

setCounter(1) --> Count is 1

You can also return a cleanup function that will run once, either before the effect re-runs or when the effect is disposed:

local getCounter, setCounter = signal(0)

local dispose = effect(function()
	local count = getCounter()
	return function()
		print(`Cleanup {count}`)
	end
end)

setCounter(1) --> Cleanup 0
dispose() --> Cleanup 1

Effects can be nested and they will clean up automatically when the parent effect re-runs or gets disposed. This means you don't have to manually clean up inner effects, and this applies to functions like subscribe() and observe().

Note

To run code that is "detached" from the parent effect or scope, use untracked() or a detached effect scope. If you suspect that the new nested effect behavior is causing issues with migration, try disabling the globals.trackInnerEffects flag to assist with debugging.

local getCounter, setCounter = signal(0)

effect(function()
	print(`Outer: {getCounter()}`)
	effect(function()
		print(`Inner: {getcounter()}`)
	end)
end)

setCounter(1) --> Outer: 1, Inner: 1
setCounter(2) --> Outer: 2, Inner: 2

untracked(callback)

In case you want to read signals that you don't want to subscribe to, you can use untracked() to essentially call a function outside the current effect, preventing signals and effects in the callback from being tracked.

local getCounter, setCounter = signal(0)
local getRuns, setRuns = signal(0)

effect(function()
	local runs = untracked(getRuns) + 1
	setRuns(runs)
	print(`Count is {getCounter()}, ran {runs} times`)
end) --> Count is 0, ran 1 times

setCounter(10) --> Count is 10, ran 2 times
setRuns(10) -- No output
setCounter(20) --> Count is 20, ran 11 times

Because untracked() executes the callback outside the current effect, nested effects created in the callback do not get tracked by the parent effect:

local disposeInner
local disposeOuter = effect(function()
	untracked(function()
		disposeInner = effect(function()
			return function()
				print("Cleaned up inner effect")
			end
		end)
	end)
end)

disposeOuter() -- No output; outer effect did not track inner effect
disposeInner() --> Cleaned up inner effect

peek(callback)

Similar to untracked(), but does not prevent effects created by the callback from automatically disposing. If your callback reads a signal and creates effects, only the signals get untracked.

local getCounter, setCounter = signal(0)
local disposeOuter = effect(function()
	peek(function()
		print(`Count is {getCounter()}`)
		effect(function()
			return function()
				print("Cleaned up inner effect")
			end
		end)
	end)
end) --> Count is 0

setCounter(1) -- No output; count was accessed in peek()
disposeOuter() --> Cleaned up inner effect

batched(callback)

Combines multiple signal updates made by the callback into a single commit that gets triggered when the callback completes.

local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe")

effect(function()
	print(`Full name: {getName()} {getSurname()}`)
end)

-- Combines both writes into a single update.
-- Once the callback completes, outputs "Full name: Foo Bar"
batched(function()
	setName("Foo")
	setSurname("Bar")
end)

effectScope(callback)

Scopes allow you to dispose multiple effects at once. The effectScope function creates an effect with no dependencies, and effects created inside the callback clean up when the effect scope disposes.

local getCounter, setCounter = signal(0)

local dispose = effectScope(function()
	effect(function()
		print(`Count 1 is {getCounter()}`)
	end)
	effect(function()
		print(`Count 2 is {getCounter()}`)
	end)
end)

setCounter(1) --> Count 1 is 1, Count 2 is 1
dispose()
setCounter(2) -- No output; effects got disposed

Similar to effect(), the callback can return a cleanup function that runs when the effect scope is disposed.


listen(getter, callback)

The listen function creates an effect that only subscribes to the signals accessed by getter. Signals accessed by the callback will not be subscribed to, avoiding accidental subscriptions when you want to run side effects.

The callback also receives the previous value, or nil when running for the first time.

local getCounter, setCounter = signal(0)

listen(getCounter, function(count, prevCount)
	print(`Count is {count} (was {prevCount})`)
end) --> Count is 0 (was nil)

setCounter(1) --> Count is 1 (was 0)

Note that nested effects can be created inside listen, and they will clean up automatically when the listener re-runs or gets disposed.


subscribe(getter, callback)

The subscribe function is identical to listen(), but the callback does not run initially. The callback only runs when the value returned by the getter function changes.

local getCounter, setCounter = signal(0)

-- Does not output anything initially
subscribe(getCounter, function(count, prevCount)
	print(`Count is {count} (was {prevCount})`)
end)

setCounter(1) --> Count is 1 (was 0)

Note that nested effects can be created inside subscribe, and they will clean up automatically when the subscription re-runs or gets disposed.


observe(getter, callback)

Observers allow you to track the lifetime of a given state. The observe function executes the callback for every unique key added to a table, and disposes the callback when that key is removed.

local getItems, setItems = signal({ a = 0, b = 0 })

observe(getItems, function(value, key)
	print(`Added {key}`)
	return function()
		print(`Removed {key}`)
	end
end) --> Added a, Added b

setItems({ a = 0, c = 0 }) --> Removed b, Added c

The callback runs in an effect scope, so effects created in the callback will be disposed when the key is removed:

local getItems, setItems = signal({ a = 0, b = 0 })

local dispose = observe(getItems, function(value, key)
	local getValue = computed(function(prevValue)
		return getItems()[key] or prevValue
	end)

	effect(function()
		local value = getValue()
		print(`{key} = {value}`)
		return function()
			print(`Cleanup {key} = {value}`)
		end
	end)
end)

setItems({ a = 1, b = 0 }) --> Cleanup a = 0, a = 1
setItems({ a = 1 }) --> Cleanup b = 0
dispose() --> Cleanup a = 1

mapped(getter, mapper)

The mapped function iterates over every key in a table and uses the mapper to generate a new key and value. The resulting table is returned in a read-only signal containing the new keys and values. When a key's value changes, or a new key is added to the table, the mapper is called for that key and its current value.

The first value returned by the mapper is used as the new value:

local getList, setList = signal({ "a", "b", "c" })

local getUppercase = mapped(getList, function(value)
	return string.upper(value)
end)

print(getUppercase()) --> { "A", "B", "C" }

If the mapper returns two values, the second value is used as the new key:

local getList, setList = signal({ "a", "b", "c" })

local getSwapped = mapped(getList, function(value, key)
	return key, value
end)

print(getSwapped()) --> { a = 1, b = 2, c = 3 }

onCleanup(callback, failSilently?)

The onCleanup function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect.

If there is no active effect, a warning is logged, unless failSilently is set to true.

local dispose = effectScope(function()
	onCleanup(function()
		print("Cleaned up")
	end)
end)

dispose() --> Cleaned up

atom(initialValue, equals?)

The atom function creates a new reactive signal and returns a single function that acts as both a getter and setter.

If the atom is called with 0 arguments, the atom returns the current value and subscribes to the signal. Otherwise, when called with 1 or more arguments, the atom will update the signal's value.

local counter = atom(0)

print(counter()) --> 0
counter(1)
counter(function(count)
	return count + 1
end)

You can also pass a custom equality function to only update the signal if the new value is not equal to the current value:

local max = atom(0, function(incoming, current)
	return incoming <= current
end)

max(1) --> 1
max(-1) --> 1

Examples

Check out this simple example project →

React Counter

local counterStore = require("./stores/counterStore")

local function Counter()
	local count = useSignalState(counterStore.getCounter)

	return React.createElement("TextButton", {
		[React.Event.Activated] = counterStore.incrementCounter,
		Text = `Count: {count}`,
		Size = UDim2.fromOffset(100, 50),
	})
end

Vide Counter

local counterStore = require("./stores/counterStore")

local function Counter()
	local count = useSignalState(counterStore.getCounter)

	return create "TextButton" {
		Activated = counterStore.incrementCounter,
		Text = function()
			return `Count: {count()}`
		end,
		Size = UDim2.fromOffset(100, 50),
	}
end

Charm is released under the MIT License.

MIT License