---
name: roblox-charm
description: Use when writing reactive state in Luau with Charm (littensy/charm) - signals, atoms, computed values, effects, observers - or syncing server state to clients with Charm Sync, or binding Charm state to Vide or React UI. Covers the Charm 0.11 API and the mistakes that throw in Studio.
---

# Atomic state with Charm

[Charm](https://github.com/littensy/charm) is fine-grained reactive state for Roblox. Store values in **signals**,
derive values with **computed**, and react with **effects**. This skill targets **Charm 0.11**. Older code using
`peek`, `atom(value, options)` or `CharmSync.server({...})` is pre-0.11: see "Migrating" below.

## Install (Wally)

```toml
[dependencies]
Charm = "littensy/charm@0.11.1"
CharmSync = "littensy/charm-sync@0.4.1"   # server -> client sync
VideCharm = "littensy/vide-charm@0.4.1"   # if the UI uses Vide
ReactCharm = "littensy/react-charm@0.4.1" # if the UI uses React
```

Check the newest versions with the Vibecoders MCP (`get_package` "littensy/charm") before pinning.

## The building blocks

```luau
local Charm = require(ReplicatedStorage.Packages.Charm)
local signal, atom, computed, effect = Charm.signal, Charm.atom, Charm.computed, Charm.effect

-- signal: separate getter and setter
local getCoins, setCoins = signal(0)
setCoins(10)
setCoins(function(coins) return coins + 5 end) -- update from the current value
print(getCoins()) -- 15

-- atom: one function that gets (no args) and sets (one arg)
local roundStatus = atom("intermission")
roundStatus("playing")
print(roundStatus()) -- "playing"

-- computed: derived, cached, read-only. Re-runs only when a dependency changed.
local canAffordSword = computed(function()
	return getCoins() >= 100
end)

-- effect: runs now, and again whenever a signal it read changes.
-- Return a function to clean up before the next run or on dispose.
local dispose = effect(function()
	label.Text = `Coins: {getCoins()}`
end)
```

Use `signal` when only some code should be able to write (export the getter, keep the setter private). Use `atom`
for simple shared values.

## Reacting to changes

| Function | Runs immediately? | Tracks | Use for |
|---|---|---|---|
| `effect(fn)` | yes | everything `fn` reads | keeping something in sync with state |
| `listen(getter, fn)` | yes | only `getter` | side effects that read other signals without subscribing to them |
| `subscribe(getter, fn)` | no, only on change | only `getter` | "when X changes, do Y" (sounds, analytics, saving) |
| `observe(getter, fn)` | once per key | keys of a table | creating and destroying something per item (per player, per inventory slot) |

```luau
Charm.subscribe(getCoins, function(coins, previous)
	if coins > previous then playSound("coin") end
end)

-- One button per inventory item; removed items clean themselves up.
Charm.observe(getInventory, function(item, id)
	local button = makeItemButton(item)
	button.Parent = grid
	return function()
		button:Destroy()
	end
end)
```

Getters can be any function that reads signals: `listen(function() return math.floor(getXp() / 100) end, ...)`
fires only when the level actually changes.

More tools:

- `batch(fn)`: make several writes, effects run once at the end.
- `untracked(fn)`: read signals inside an effect without subscribing (was `peek` before 0.11).
- `effectScope(fn)`: group effects and dispose them together. `onCleanup(fn)` adds a cleanup to the current
  effect or scope.
- `mapped(getter, transform)`: a signal of a table, transformed per key.
- `trigger(fn)`: tell dependents a value changed after mutating it in place (prefer immutable updates instead).

## Immutable updates (this throws in Studio otherwise)

Charm compares by reference, and in Studio the `frozen` flag deep-freezes tables you put in a signal. Mutating a
stored table errors in Studio and silently fails to update in a live server. Always copy, change, set:

```luau
setInventory(function(inventory)
	local nextInventory = table.clone(inventory)
	nextInventory[itemId] = { count = (inventory[itemId] and inventory[itemId].count or 0) + 1 }
	return nextInventory
end)
```

The `strict` flag (also on in Studio) errors if an effect, computed or signal callback yields. No `task.wait`,
`:WaitForChild` or DataStore calls inside them: do the yielding work outside and write the result into a signal.

## Structure

```luau
-- ReplicatedStorage/Shared/Store.luau: one place that owns shared state
local Charm = require(ReplicatedStorage.Packages.Charm)

local getRound, setRound = Charm.signal("intermission")
local getTimer, setTimer = Charm.signal(0)
local getPlayers, setPlayers = Charm.signal({} :: { [string]: PlayerState })

return {
	getRound = getRound, setRound = setRound,
	getTimer = getTimer, setTimer = setTimer,
	getPlayers = getPlayers, setPlayers = setPlayers,
}
```

Load saved data into the store when a player joins, write it back from the store when they leave. Charm is the
runtime state, your data layer is the storage.

## Syncing server state to clients (Charm Sync)

The server picks which signals each client receives; changes go out as delta patches over one remote. In 0.4+,
`CharmSync.server` and `CharmSync.client` are singletons.

```luau
-- Server
local CharmSync = require(ReplicatedStorage.Packages.CharmSync)
local server = CharmSync.server

ready.OnServerEvent:Connect(function(player)
	server.addSignalsToClient(player, {
		round = Store.getRound,
		timer = Store.getTimer,
		-- Per-player state needs a key unique to that player
		[`player-{player.UserId}`] = Charm.computed(function()
			return Store.getPlayers()[tostring(player.UserId)]
		end),
	})
end)

Players.PlayerRemoving:Connect(function(player)
	server.removeClient(player)
end)

server.connect(function(player, updates)
	syncRemote:FireClient(player, updates)
end)
```

```luau
-- Client
local client = CharmSync.client
local getMe, setMe = Charm.signal(nil :: PlayerState?)

client.addSignals({
	round = Store.setRound,
	timer = Store.setTimer,
	[`player-{Players.LocalPlayer.UserId}`] = setMe,
})

syncRemote.OnClientEvent:Connect(function(updates)
	client.patch(updates)
end)
ready:FireServer()
```

- Only sync what the client needs. Never sync other players' private data.
- Removed values arrive as `{ __none = "__none" }` in patches. Handle that if you serialize the payload yourself.
- The client copy is for display. Requests still go to the server through remotes, and the server validates.
- `Charm.signalToAtom(getter, setter)` lets one table of atoms serve both `addSignals` and `addSignalsToClient`.

## UI

```luau
-- Vide
local VideCharm = require(ReplicatedStorage.Packages.VideCharm)
local coins = VideCharm.useSignalState(Store.getCoins)
create "TextLabel" { Text = function() return `Coins: {coins()}` end }

-- React
local ReactCharm = require(ReplicatedStorage.Packages.ReactCharm)
local coins = ReactCharm.useSignalState(Store.getCoins)
```

Without a UI library, bind with `effect()` and dispose it when the UI is destroyed.

## Migrating from Charm before 0.11

- `peek()` is now `untracked()`.
- `atom(value, { equals = ... })` is now `atom(value, equalsFn)`.
- `computed(fn, equals)` lost its second argument; `fn` receives the previous value instead.
- Effects no longer get a `cleanup` argument: return a cleanup function or call `onCleanup`.
- Charm Sync: replace `CharmSync.server({ atoms = ... })` syncers with `server.addSignalsToClient` per player and
  `client.addSignals` + `client.patch`.
- Nested effects now clean up with their parent. Wrap ones that must outlive it in `untracked()`.
