mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
781 lines
24 KiB
Markdown
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">
|
|
|
|

|
|
[](https://www.npmjs.com/package/@rbxts/charm)
|
|
[](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">
|
|
|
|
[](LICENSE.md)
|
|
|
|
</div>
|