docs: improve intro and explain getters

This commit is contained in:
littensy 2026-02-17 18:38:30 -08:00
parent fe57482aaf
commit fe7064acee

114
README.md
View file

@ -4,7 +4,7 @@
</p>
<h1 align="center"><b>Charm</b></h1>
<p align="center">
Manage state with reactive signals
Fine-grained reactivity for Roblox
<br />
<a href="https://npmjs.com/package/@rbxts/charm"><strong>npm package →</strong></a>
</p>
@ -18,18 +18,17 @@
</div>
**Charm** is a Roblox state management library inspired by the [reactive signals](https://preactjs.com/blog/introducing-signals/) from libraries like Preact and Solid. It's built on [alien-signals](https://github.com/stackblitz/alien-signals), modified for a wide range of use cases: from rendering user interfaces to handling game logic.
Charm is a state management library that brings the [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) approach — used in libraries like Vue, Preact, and Solid — to your Luau projects.
**Charm works under a few core principles.**
**Build complex game state from simple building blocks:**
- 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
- Data should be immutable: values are compared directly (`==`) to optimize change detection
- Store state in [reactive signals](#signalinitialvalue-equals): state containers that hold a value
- React to state changes with [effects](#effectcallback): run code whenever a dependency updates
- Derive new values with [computed signals](#computedgetter): memoize a function with automatic dependency tracking
**Want to learn more about signals?**
- https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf
- https://preactjs.com/blog/introducing-signals
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
- https://angular.dev/guide/signals
@ -44,12 +43,14 @@
- [`signal(initialValue, equals?)`](#signalinitialvalue-equals)
- [`computed(getter)`](#computedgetter)
- [`effect(callback)`](#effectcallback)
- [Nested effects](#nested-effects)
- [`reactive(initialValue)`](#reactiveinitialvalue)
- [`untracked(callback)`](#untrackedcallback)
- [`peek(callback)`](#peekcallback)
- [`batch(callback)`](#batchcallback)
- [`effectScope(callback)`](#effectscopecallback)
- [`listen(getter, callback)`](#listengetter-callback)
- [Getter functions](#getter-functions)
- [`subscribe(getter, callback)`](#subscribegetter-callback)
- [`observe(getter, callback)`](#observegetter-callback)
- [`mapped(getter, mapper)`](#mappedgetter-mapper)
@ -150,6 +151,7 @@ pnpm add @rbxts/charm
```
```toml
# wally.toml
[dependencies]
Charm = "littensy/charm@VERSION"
```
@ -187,42 +189,7 @@ setMax(-1) -- 2
```
> [!NOTE]
> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a combined getter and setter.
---
### `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.
> Looking for atoms? You can still use [`atom()`](#atominitialvalue-equals) to create a signal with a unified getter and setter.
---
@ -288,6 +255,41 @@ Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen
---
### `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.
---
### `reactive(initialValue)`
By default, Charm's signals are _shallowly reactive_, meaning only the value itself is reactive, and table properties are not tracked. Signal values must also be **immutable** in order to track changes in tables.
@ -444,13 +446,33 @@ end)
setCounter(1) -- Count is 1 (was 0)
```
### Getter functions
Reaction APIs like `listen()`, `subscribe()`, and `observe()` aren't only restricted to signals. You can listen to "getter" functions that call one or more signals, and they will automatically be memoized:
```luau
local getCounter, setCounter = signal(0)
local function floorCounter()
return math.floor(getCounter())
end
-- Output: Floor of count is 0 (was nil)
listen(floorCounter, function(count, prevCount)
print(`Floor of count is {count} (was {prevCount})`)
end)
setCounter(0.5) -- Doesn't print anything, floor is still 0
setCounter(1) -- Floor of 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.
The `subscribe` function is identical to `listen()`, but the effect callback skips the initial run. The callback only runs when the value returned by the getter function changes.
```luau
local getCounter, setCounter = signal(0)
@ -463,8 +485,6 @@ 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)`