mirror of
https://github.com/littensy/charm.git
synced 2026-10-20 17:51:37 +00:00
docs: rephrase some sections
This commit is contained in:
parent
89478f679c
commit
3460618ee0
1 changed files with 72 additions and 69 deletions
137
README.md
137
README.md
|
|
@ -18,13 +18,13 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
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 is a state management library based on an approach to reactive programming called [fine-grained reactivity](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf). Store your game's state in signals, and your code will stay in sync with the data it depends on.
|
||||||
|
|
||||||
**Build complex game state from simple building blocks:**
|
**Build complex state from three reactive primitives:**
|
||||||
|
|
||||||
- Store state in [reactive signals](#signalinitialvalue-equals): state containers that hold a value
|
- Store state in [signals](#signalinitialvalue-equals): state containers that hold a value
|
||||||
- React to state changes with [effects](#effectcallback): run code whenever a dependency updates
|
- React to state changes with [effects](#effectcallback): run code whenever a signal updates
|
||||||
- Derive new values with [computed signals](#computedgetter): memoize a function with automatic dependency tracking
|
- Derive new values with [computed signals](#computedgetter): memoized functions with automatic dependency tracking
|
||||||
|
|
||||||
**Want to learn more about signals?**
|
**Want to learn more about signals?**
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ Charm is a state management library that brings the [fine-grained reactivity](ht
|
||||||
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
|
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
|
||||||
- https://angular.dev/guide/signals
|
- https://angular.dev/guide/signals
|
||||||
|
|
||||||
[Migrating from an older version?](#migration)
|
[Migrating from an older version of Charm?](#migration)
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Table of Contents</b></summary>
|
<summary><b>Table of Contents</b></summary>
|
||||||
|
|
@ -77,19 +77,19 @@ Charm is a state management library that brings the [fine-grained reactivity](ht
|
||||||
## At a Glance
|
## At a Glance
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getTodos, setTodos = signal({} :: { [number]: string })
|
local getTodos, setTodos = signal({} :: { string })
|
||||||
local getQuery, setQuery = signal("")
|
local getQuery, setQuery = signal("")
|
||||||
|
|
||||||
observe(getTodos, function(todo, index)
|
observe(getTodos, function(todo: string, index: number)
|
||||||
local instance = Instance.new("TextLabel")
|
local instance = Instance.new("TextLabel")
|
||||||
|
|
||||||
local getText = computed(function(prevText)
|
local getText = computed(function()
|
||||||
return getTodos()[index] or prevText
|
return getTodos()[index] or ""
|
||||||
end)
|
end)
|
||||||
|
|
||||||
effect(function()
|
effect(function()
|
||||||
instance.Text = getText()
|
instance.Text = getText()
|
||||||
instance.Visible = getText():match(getQuery()) ~= nil
|
instance.Visible = string.match(getText(), getQuery()) ~= nil
|
||||||
end)
|
end)
|
||||||
|
|
||||||
instance.LayoutOrder = index
|
instance.LayoutOrder = index
|
||||||
|
|
@ -102,30 +102,31 @@ observe(getTodos, function(todo, index)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
|
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
|
||||||
setQuery("^Buy")
|
setQuery("Buy")
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Explain code</summary>
|
<summary>Explain code</summary>
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getTodos, setTodos = signal({} :: { [number]: string })
|
-- Declare state for a todo list and a search query
|
||||||
|
local getTodos, setTodos = signal({} :: { string })
|
||||||
local getQuery, setQuery = signal("")
|
local getQuery, setQuery = signal("")
|
||||||
|
|
||||||
-- Create a text label when an item is added to the list
|
-- Create a text label when an item is added to the list
|
||||||
observe(getTodos, function(todo, index)
|
observe(getTodos, function(todo, index)
|
||||||
local instance = Instance.new("TextLabel")
|
local instance = Instance.new("TextLabel")
|
||||||
|
|
||||||
-- Create a computed signal that stores the current text, or the previous
|
-- Create a memoized function that only re-runs effects when the todo list
|
||||||
-- value if the item was removed
|
-- updates or the function returns new text
|
||||||
local getText = computed(function(prevText)
|
local getText = computed(function()
|
||||||
return getTodos()[index] or prevText
|
return getTodos()[index] or ""
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- Set properties when the text or query updates
|
-- Update instance properties when the text or query updates
|
||||||
effect(function()
|
effect(function()
|
||||||
instance.Text = getText()
|
instance.Text = getText()
|
||||||
instance.Visible = getText():match(getQuery()) ~= nil
|
instance.Visible = string.match(getText(), getQuery()) ~= nil
|
||||||
end)
|
end)
|
||||||
|
|
||||||
instance.LayoutOrder = index
|
instance.LayoutOrder = index
|
||||||
|
|
@ -140,8 +141,8 @@ end)
|
||||||
|
|
||||||
-- Add items to the todo list
|
-- Add items to the todo list
|
||||||
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
|
setTodos({ "Buy milk", "Buy eggs", "Play Roblox" })
|
||||||
-- Filter for items starting with "Buy"
|
-- Filter for items containing "Buy"
|
||||||
setQuery("^Buy")
|
setQuery("Buy")
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -232,24 +233,24 @@ dispose() -- Cleanup 1
|
||||||
Effects can be nested inside other effects. When the outer effect re-runs, inner effects from the previous run are automatically cleaned up, and new inner effects are created if needed. The system ensures proper execution order; outer effects always run before their inner effects:
|
Effects can be nested inside other effects. When the outer effect re-runs, inner effects from the previous run are automatically cleaned up, and new inner effects are created if needed. The system ensures proper execution order; outer effects always run before their inner effects:
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getShow, setShow = signal(true)
|
local getPrintCount, setPrintCount = signal(true)
|
||||||
local getCount, setCount = signal(1)
|
local getCount, setCount = signal(1)
|
||||||
|
|
||||||
effect(function()
|
effect(function()
|
||||||
if getShow() then
|
if getPrintCount() then
|
||||||
-- This inner effect is created when show() is true
|
-- This inner effect is created when getPrintCount() is true
|
||||||
effect(function()
|
effect(function()
|
||||||
print(`Count is: {getCount()}`)
|
print(`Count is {getCount()}`)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
end) -- Output: Count is: 1
|
end) -- Count is 1
|
||||||
|
|
||||||
setCount(2) -- Count is: 2
|
setCount(2) -- Count is 2
|
||||||
|
|
||||||
-- When show becomes false, the inner effect is cleaned up
|
-- This re-runs the outer effect and cleans up old inner effects
|
||||||
setShow(false) -- No output
|
setPrintCount(false)
|
||||||
|
|
||||||
setCount(3) -- No output (inner effect no longer exists)
|
setCount(3) -- No output
|
||||||
```
|
```
|
||||||
|
|
||||||
Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen()`, `observe()`) because they are built upon effects.
|
Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen()`, `observe()`) because they are built upon effects.
|
||||||
|
|
@ -261,9 +262,7 @@ Nested effect cleanup also applies to every reaction API (`subscribe()`, `listen
|
||||||
|
|
||||||
### `computed(getter)`
|
### `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` function creates a read-only signal whose value is derived from other signals. The computed signal caches the getter function's last result, and the value is only re-computed if a dependency has updated since the last computation.
|
||||||
|
|
||||||
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
|
```luau
|
||||||
local getName, setName = signal("John")
|
local getName, setName = signal("John")
|
||||||
|
|
@ -277,7 +276,7 @@ setName("Jane")
|
||||||
print(getFullName()) -- "Jane Doe"
|
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:
|
The getter function also receives the previous result (or `nil` during the initial run). You can use this for computed signals that depend on the previous result:
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getCounter, setCounter = signal(10)
|
local getCounter, setCounter = signal(10)
|
||||||
|
|
@ -290,13 +289,11 @@ setCounter(5)
|
||||||
print(getMax()) -- 10
|
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `effectScope(callback)`
|
### `effectScope(callback)`
|
||||||
|
|
||||||
Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created inside the callback clean up when the scope disposes.
|
Scopes allow you to dispose multiple effects at once. The `effectScope` function creates a scope that tracks inner effects, so effects created during the execution of the callback will clean up when the scope disposes.
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getCounter, setCounter = signal(0)
|
local getCounter, setCounter = signal(0)
|
||||||
|
|
@ -328,10 +325,9 @@ The callback also receives the previous value, or `nil` when running for the fir
|
||||||
```luau
|
```luau
|
||||||
local getCounter, setCounter = signal(0)
|
local getCounter, setCounter = signal(0)
|
||||||
|
|
||||||
-- Output: Count is 0 (was nil)
|
|
||||||
listen(getCounter, function(count, prevCount)
|
listen(getCounter, function(count, prevCount)
|
||||||
print(`Count is {count} (was {prevCount})`)
|
print(`Count is {count} (was {prevCount})`)
|
||||||
end)
|
end) -- Count is 0 (was nil)
|
||||||
|
|
||||||
setCounter(1) -- Count is 1 (was 0)
|
setCounter(1) -- Count is 1 (was 0)
|
||||||
```
|
```
|
||||||
|
|
@ -349,10 +345,9 @@ local function floorCounter()
|
||||||
return math.floor(getCounter())
|
return math.floor(getCounter())
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Output: Floor of count is 0 (was nil)
|
|
||||||
listen(floorCounter, function(count, prevCount)
|
listen(floorCounter, function(count, prevCount)
|
||||||
print(`Floor of count is {count} (was {prevCount})`)
|
print(`Floor of count is {count} (was {prevCount})`)
|
||||||
end)
|
end) -- Floor of count is 0 (was nil)
|
||||||
|
|
||||||
setCounter(0.5) -- Doesn't print anything, floor is still 0
|
setCounter(0.5) -- Doesn't print anything, floor is still 0
|
||||||
setCounter(1) -- Floor of count is 1 (was 0)
|
setCounter(1) -- Floor of count is 1 (was 0)
|
||||||
|
|
@ -367,13 +362,12 @@ setCounter(1) -- Floor of count is 1 (was 0)
|
||||||
```luau
|
```luau
|
||||||
local getItems, setItems = signal({ a = 0, b = 0 })
|
local getItems, setItems = signal({ a = 0, b = 0 })
|
||||||
|
|
||||||
-- Added a, Added b
|
|
||||||
observe(getItems, function(value, key)
|
observe(getItems, function(value, key)
|
||||||
print(`Added {key}`)
|
print(`Added {key}`)
|
||||||
return function()
|
return function()
|
||||||
print(`Removed {key}`)
|
print(`Removed {key}`)
|
||||||
end
|
end
|
||||||
end)
|
end) -- Added a, Added b
|
||||||
|
|
||||||
setItems({ a = 0, c = 0 }) -- Removed b, Added c
|
setItems({ a = 0, c = 0 }) -- Removed b, Added c
|
||||||
```
|
```
|
||||||
|
|
@ -431,7 +425,7 @@ local getUntracked, setUntracked = signal(0)
|
||||||
|
|
||||||
effect(function()
|
effect(function()
|
||||||
print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
|
print(`Tracked: {getTracked()}, Untracked: {untracked(getUntracked)}`)
|
||||||
end) -- Output: Tracked: 0, Untracked: 0
|
end) -- Tracked: 0, Untracked: 0
|
||||||
|
|
||||||
setTracked(1) -- Tracked: 1, Untracked: 0
|
setTracked(1) -- Tracked: 1, Untracked: 0
|
||||||
setUntracked(1) -- No output
|
setUntracked(1) -- No output
|
||||||
|
|
@ -467,7 +461,7 @@ local getCounter, setCounter = signal(0)
|
||||||
|
|
||||||
local disposeOuter = effect(function()
|
local disposeOuter = effect(function()
|
||||||
print(`Count is {peek(getCounter)}`)
|
print(`Count is {peek(getCounter)}`)
|
||||||
end) -- Output: Count is 0
|
end) -- Count is 0
|
||||||
|
|
||||||
setCounter(1) -- No output; count was accessed in peek()
|
setCounter(1) -- No output; count was accessed in peek()
|
||||||
```
|
```
|
||||||
|
|
@ -530,7 +524,7 @@ print(getSwapped()) -- { a = 1, b = 2, c = 3 }
|
||||||
|
|
||||||
The `onCleanup` function binds the callback to the currently running effect or effect scope. Multiple cleanup functions can be bound to the same effect.
|
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`.
|
Unless `failSilently` is set to `true`, this function will emit a warning if there is no active effect or scope.
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local dispose = effectScope(function()
|
local dispose = effectScope(function()
|
||||||
|
|
@ -621,12 +615,12 @@ print(total()) -- 2
|
||||||
Charm exposes the following global flags to customize behavior:
|
Charm exposes the following global flags to customize behavior:
|
||||||
|
|
||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
| ----------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| strict | `true` if not `O2` | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
|
| strict | `true`\* | Enforces synchronous, non-yielding behavior in signals, effects, and other critical code. |
|
||||||
| frozen | `true` if not `O2` | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. |
|
| frozen | `true`\* | Enforces data immutability by deep-freezing tables passed to signals, excluding objects with metatables. |
|
||||||
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
|
| trackInnerEffects | `true` | Whether nested effects should be tracked and cleaned up when the parent effect re-runs. This should only be disabled to debug issues during migration. |
|
||||||
|
|
||||||
The `strict` and `frozen` flags are automatically enabled in Roblox Studio. More accurately, they are enabled for the Luau optimization levels `O1` and lower.
|
The `strict` and `frozen` flags are automatically enabled in Roblox Studio and other development environments where the Luau optimization level is `O1` or lower.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -746,7 +740,7 @@ Players.PlayerAdded:Connect(function(player)
|
||||||
end)
|
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:
|
You're also allowed to create new signals to sync to specific players, as long as the key is unique to that player:
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
server.addSignalsToClient(player, {
|
server.addSignalsToClient(player, {
|
||||||
|
|
@ -899,11 +893,11 @@ This means nilable values may be replaced with `None` in patches, and code worki
|
||||||
|
|
||||||
## Deep Reactivity
|
## Deep Reactivity
|
||||||
|
|
||||||
By default, Charm's signals are not deeply reactive. This means that only the value itself is tracked, and table properties are not checked for changes. As a result, signal values must be **immutable** in order to detect changes in tables.
|
Charm's reactivity system is _shallow_ by default: only the top-level value is reactive, so table properties are not checked when determining whether a signal has updated. As a result, tables in Charm should be immutable (copied before writing) in order to signal that a table's properties have changed.
|
||||||
|
|
||||||
With deep reactivity, you can mutate tables directly and detect changes in nested properties without cloning tables. In Charm, this is done by wrapping your tables in proxy tables that handle reactivity for you.
|
Deep reactivity, on the other hand, uses proxy tables to perform dependency tracking on properties. You can subscribe to a property by indexing the proxy table, and setting a property will notify its subscribers. This approach to reactivity lets you work with mutable data, making state management more intuitive at the cost of added overhead.
|
||||||
|
|
||||||
You can opt-in to deep reactivity with **reactive proxies**:
|
You can opt-in to deep reactivity with the Deep Charm library:
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
|
|
@ -922,7 +916,7 @@ DeepCharm = "littensy/deep-charm@VERSION"
|
||||||
|
|
||||||
### `reactive(initialValue)`
|
### `reactive(initialValue)`
|
||||||
|
|
||||||
The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and table values will also be wrapped in a reactive proxy.
|
The `reactive()` function takes a mutable table and wraps it in a reactive proxy. Reading properties through the proxy will perform dependency tracking, and nested tables will also be wrapped in a reactive proxy.
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local users, updateUsers = reactive({
|
local users, updateUsers = reactive({
|
||||||
|
|
@ -933,10 +927,10 @@ effect(function()
|
||||||
print(`{users[1].name} {users[1].surname}`)
|
print(`{users[1].name} {users[1].surname}`)
|
||||||
end) -- Output: John Doe
|
end) -- Output: John Doe
|
||||||
|
|
||||||
updateUsers(function(state)
|
updateUsers(function(raw)
|
||||||
state[1].name = "Jane"
|
raw[1].name = "Jane"
|
||||||
state[1].surname = "Smith"
|
raw[1].surname = "Smith"
|
||||||
table.insert(state, { name = "Steve", surname = "Doe" })
|
table.insert(raw, { name = "Steve", surname = "Doe" })
|
||||||
end) -- Output: Jane Smith
|
end) -- Output: Jane Smith
|
||||||
|
|
||||||
-- You can also mutate the reactive proxy directly:
|
-- You can also mutate the reactive proxy directly:
|
||||||
|
|
@ -1019,11 +1013,10 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
|
||||||
> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI.
|
> This meant effects created as a side effect of a source update would implicitly get added as a child of the `useAtom` effect, and they could get disposed at the wrong time and desync UI.
|
||||||
|
|
||||||
5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
|
5. Consider refactoring your code to use some new quality-of-life features. Many of these are made possible thanks to [alien-signals](https://github.com/stackblitz/alien-signals)!
|
||||||
- [`reactive()`](#reactiveinitialvalue): make mutable tables deeply reactive
|
- [`signal()`](#signalinitialvalue-equals): make atom reads and writes more explicit
|
||||||
- [`signal()`](#signalinitialvalue-equals): make reads and writes more explicit
|
- [`listen()`](#listengetter-callback): create a subscription that also runs once immediately
|
||||||
- [`listen()`](#listengetter-callback): create a subscription that runs once immediately
|
|
||||||
- [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
|
- [`effectScope()`](#effectscopecallback): collect and clean up multiple effects at once
|
||||||
- [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect
|
- [`onCleanup()`](#oncleanupcallback-failsilently): bind a cleanup function to the active effect or scope
|
||||||
- [`trigger()`](#triggercallback): trigger updates for table mutations
|
- [`trigger()`](#triggercallback): trigger updates for table mutations
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -1035,10 +1028,14 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
|
||||||
### React Counter
|
### React Counter
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getCounter, setCounter = signal(0)
|
local Charm = require("@packages/charm")
|
||||||
|
local ReactCharm = require("@packages/react-charm")
|
||||||
|
local React = require("@packages/react")
|
||||||
|
|
||||||
|
local getCounter, setCounter = Charm.signal(0)
|
||||||
|
|
||||||
local function Counter()
|
local function Counter()
|
||||||
local count = useSignalState(getCounter)
|
local count = ReactCharm.useSignalState(getCounter)
|
||||||
|
|
||||||
return React.createElement("TextButton", {
|
return React.createElement("TextButton", {
|
||||||
[React.Event.Activated] = function()
|
[React.Event.Activated] = function()
|
||||||
|
|
@ -1053,10 +1050,16 @@ end
|
||||||
### Vide Counter
|
### Vide Counter
|
||||||
|
|
||||||
```luau
|
```luau
|
||||||
local getCounter, setCounter = signal(0)
|
local Charm = require("@packages/charm")
|
||||||
|
local VideCharm = require("@packages/vide-charm")
|
||||||
|
local Vide = require("@packages/vide")
|
||||||
|
|
||||||
|
local create = Vide.create
|
||||||
|
|
||||||
|
local getCounter, setCounter = Charm.signal(0)
|
||||||
|
|
||||||
local function Counter()
|
local function Counter()
|
||||||
local count = useSignalState(getCounter)
|
local count = VideCharm.useSignalState(getCounter)
|
||||||
|
|
||||||
return create "TextButton" {
|
return create "TextButton" {
|
||||||
Activated = function()
|
Activated = function()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue