charm/README.md
2025-11-03 00:12:06 -08:00

781 lines
24 KiB
Markdown

<p align="center">
<p align="center">
<img width="150" height="150" src="https://raw.githubusercontent.com/littensy/charm/main/assets/logo.png" alt="Logo">
</p>
<h1 align="center"><b>Charm</b></h1>
<p align="center">
Manage state with reactive signals
<br />
<a href="https://npmjs.com/package/@rbxts/charm"><strong>npm package →</strong></a>
</p>
</p>
<div align="center">
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github)
[![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm)
[![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)
</div>
**Charm** is a state management library that aims to bring [reactive signals](https://preactjs.com/blog/signal-boosting/) from libraries like Preact and Solid to Roblox. It's built on the [alien-signals](https://github.com/stackblitz/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 works under 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 new values from existing state that stay 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?**
- https://preactjs.com/blog/signal-boosting
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
- https://angular.dev/guide/signals
<details>
<summary><b>Table of Contents</b></summary>
- [Installation](#installation)
- [Reference](#reference)
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
- [`computed(getter)`](#computedgetter)
- [`effect(callback)`](#effectcallback)
- [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- [`batched(callback)`](#batchedcallback)
- [`effectScope(callback)`](#effectscopecallback)
- [`listen(getter, callback)`](#listengetter-callback)
- [`subscribe(getter, callback)`](#subscribegetter-callback)
- [`observe(getter, callback)`](#observegetter-callback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [Examples](#examples)
</details>
## At a Glance
```luau
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")
```
<details>
<summary>Explain code</summary>
```luau
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")
```
</details>
## Installation
```sh
npm install @rbxts/charm
yarn add @rbxts/charm
pnpm add @rbxts/charm
```
```toml
[dependencies]
Charm = "littensy/charm@VERSION"
```
---
## Reference
### `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.
```luau
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:
```luau
local getMax, setMax = signal(0, function(current, incoming)
return incoming <= current
end)
setMax(1) --> 1
setMax(2) --> 2
setMax(-1) --> 2
```
> [!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.
```luau
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:
```luau
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.
```luau
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:
```luau
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()`.
```luau
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
```
> [!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.
---
### `untracked(callback)`
In case you want to read signals but don't want to subscribe to them, you can use `untracked()` to essentially run code _outside_ the current effect, preventing signals and effects in the callback from being tracked.
```luau
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:
```luau
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 the parent effect from tracking nested effects created in the callback. If your callback reads a signal and creates effects, only the signals get untracked.
```luau
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 triggers effects once the callback completes.
```luau
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.
```luau
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.
```luau
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.
```luau
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](https://sleitnick.github.io/RbxObservers/docs/observer-pattern) 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.
```luau
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:
```luau
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 assign them to a new key and value. The result is returned as 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:
```luau
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:
```luau
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`.
```luau
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.
```luau
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:
```luau
local max = atom(0, function(current, incoming)
return incoming <= current
end)
max(1) --> 1
max(-1) --> 1
```
---
### `globals`
Global flags that customize the behavior of Charm. The `strict` and `frozen` flags are automatically enabled when the Luau optimization level is less than `2`, which is true in Roblox Studio.
| Flag | Default | Description |
| ----------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| strict | `true` in Studio | Enforces synchronous, non-yielding behavior in signals, effects, and critical code. |
| frozen | `true` in Studio | Enforces immutability by deep-freezing tables passed to signals. |
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. Should only be disabled to debug issues during migration. |
---
## Client-server sync
### Installation
```sh
npm install @rbxts/charm-sync
yarn add @rbxts/charm-sync
pnpm add @rbxts/charm-sync
```
```toml
[dependencies]
CharmSync = "littensy/charm-sync@VERSION"
```
### Quick start
Start by specifying the signals that the server should sync to clients. For this example, we'll use the first and last name signals:
```luau
-- nameStore
local getName, setName = signal("John")
local getSurname, setSurname = signal("Doe")
return {
getName = getName,
setName = setName,
getSurname = getSurname,
setSurname = setSurname,
}
```
When a player joins on the server, call `server.addSignalsToClient` with the keyed signals that the client should receive updates for. Once they leave, call `server.removeClient` to unsubscribe them from all updates.
Then, use `server.connect` to specify how state updates should be sent to each client. Pass a callback function that fires a remote with the given target player and the state updates they subscribed to.
> [!NOTE]
> On the server, make sure each key corresponds to the same signal across all players. If two players subscribe to the same key, but were given different signals, Charm will output a warning.
```luau
local function onPlayerAdded(player: Player)
-- Add signal getters, computed signals, or atoms
server.addSignalsToClient(player, {
name = nameStore.getName,
surname = nameStore.getSurname,
})
end
for _, player in Players:GetPlayers() do
onPlayerAdded(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(function(player)
server.removeClient(player)
end)
server.connect(function(player, updates)
-- Customize how you send state updates to clients
syncEvent:FireClient(player, updates)
end)
```
To sync the client with the server's state, call `client.addSignals` with a table of writable signals (setter functions or atoms) whose keys match their server counterparts.
After the client receives updates from the server, call `client.hydrate` to patch the client's signals with the incoming state updates.
```luau
-- Add signal setters or atoms
client.addSignals({
name = nameStore.setName,
surname = nameStore.setSurname,
})
-- Update client signals when receiving updates from the server
syncEvent.OnClientEvent:Connect(function(updates)
client.hydrate(updates)
end)
```
---
### `config`
A configuration table that customizes the behavior of Charm Sync on the server.
| Option | Default | Description |
| --------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| interval | `0` | The frequency at which the server will send patches to the client, in seconds. A value of `0` sends updates on the next frame. Set to a negative value to disable the interval. |
| preserveHistory | `false` | Whether to preserve a full history of state changes since the last sync, at the cost of performance. This is useful if you need to replicate each individual change that occurs between sync events. |
| fixArrays | `true` | When `true`, Charm will attempt to work around Roblox remote event limitations regarding array patches. Disable this if your networking library serializes remote arguments (Zap, ByteNet, etc.). |
| validatePatches | `true` | When `true`, and both `fixArrays` and strict mode are enabled, patches will be validated to enforce remote event limitations. |
---
### `server.addSignalsToClient(client, getters)`
The `addSignalsToClient` function subscribes a client to updates in the given signals. When an update occurs, the client will receive a state patch of only the values that changed.
You can pass signal getter functions, computed signals, and atoms in the `getters` table. This function can also be called multiple on the same client to subscribe to new signals.
```luau
Players.PlayerAdded:Connect(function(player)
server.addSignalsToClient(player, {
name = nameStore.getName,
surname = nameStore.getSurname,
})
end)
```
You're also allowed to create new signals to sync to specific players, as long as the key is also unique to that player:
```luau
server.addSignalsToClient(player, {
[`data-{player.UserId}`] = computed(function()
return getPlayerData(player.UserId)
end),
})
```
---
### `server.removeSignalsFromClient(client, ...keys)`
The `removeSignalsFromClient` function unsubscribes the client from a list of keys that were previously subscribed to via `addSignalsToClient`.
```luau
server.addSignalsToClient(player, {
surname = nameStore.getSurname,
})
server.removeSignalsFromClient(player, "surname")
```
---
### `server.removeClient(client)`
The `removeClient` function unsubscribes a client from receiving all state updates from the server. You should call this function when a player leaves the game.
```luau
Players.PlayerAdded:Connect(function(player)
server.addSignalsToClient(player, signals)
end)
Players.PlayerRemoving:Connect(function(player)
server.removeClient(player)
end)
```
---
### `server.connect(onSync)`
Binds a callback to run when sending state updates a client. Scheduled updates will be sent periodically at the interval specified in [`config.interval`](#config).
When a synced signal updates, the `onSync` function is scheduled to run for each client subscribed to that signal on the next sync event.
```luau
server.connect(function(player, updates)
syncEvent:FireServer(player, updates)
end)
```
---
### `server.disconnect()`
Stops syncing state updates to clients at the automatic interval. This doesn't unbind the last callback, so you can still manually trigger sync events after calling this function by calling `server.flush()`.
```luau
server.connect(function(player, updates)
syncEvent:FireServer(player, updates)
end)
-- Stops calling the function at the automatic interval
server.disconnect()
-- Flushing still sends pending updates
server.flush()
```
---
## Examples
Check out this simple [example project →](https://github.com/littensy/charm-example)
### React Counter
```luau
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
```luau
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
```
---
<p align="center">
Charm is released under the <a href="LICENSE.md">MIT License</a>.
</p>
<div align="center">
[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)
</div>