docs: update readme

This commit is contained in:
littensy 2025-11-12 16:43:49 -08:00
parent 898fbdba29
commit 29d8add265

114
README.md
View file

@ -18,7 +18,7 @@
</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** 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 works under a few core principles.**
@ -26,11 +26,11 @@
- 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
- Data should be immutable: values are compared directly (`==`) to optimize change detection
**Want to learn more about signals?**
- https://preactjs.com/blog/signal-boosting
- https://preactjs.com/blog/introducing-signals
- https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity
- https://angular.dev/guide/signals
@ -52,6 +52,8 @@
- [`mapped(getter, mapper)`](#mappedgetter-mapper)
- [`onCleanup(callback, failSilently?)`](#oncleanupcallback-failsilently)
- [`atom(initialValue, equals?)`](#atominitialvalue-equals)
- [`trigger(callback)`](#triggercallback)
- [`flags`](#flags)
- [Client-Server Sync](#client-server-sync)
- [Installation](#installation-1)
- [Quick Start](#quick-start)
@ -159,25 +161,25 @@ Signals are the core of reactivity in Charm. The `signal` function creates a rea
```luau
local getCounter, setCounter = signal(0)
print(getCounter()) --> 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 notify every effect and computed signal that depends on said signal, ensuring all of your state is correct and up-to-date.
Accessing the signal's value in an effect or computed signal will subscribe to it as a dependency. Changing the value will immediately notify every effect and computed signal that depends on the signal, ensuring all 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:
You can also pass a custom equality function to only update the signal if the equality function returns `false`:
```luau
local getMax, setMax = signal(0, function(current, incoming)
return incoming <= current
end)
setMax(1) --> 1
setMax(2) --> 2
setMax(-1) --> 2
setMax(1) -- 1
setMax(2) -- 2
setMax(-1) -- 2
```
> [!NOTE]
@ -198,9 +200,9 @@ local getFullName = computed(function()
return `{getName()} {getSurname()}`
end)
print(getFullName()) --> "John Doe"
print(getFullName()) -- "John Doe"
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:
@ -211,9 +213,9 @@ local getMax = computed(function(prevMax)
return math.max(getCounter(), prevMax or 0)
end)
print(getMax()) --> 10
print(getMax()) -- 10
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.
@ -229,9 +231,9 @@ local getCounter, setCounter = signal(0)
effect(function()
print(`Count is {getCounter()}`)
end) --> Count is 0
end) -- Count is 0
setCounter(1) --> Count is 1
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:
@ -246,8 +248,8 @@ local dispose = effect(function()
end
end)
setCounter(1) --> Cleanup 0
dispose() --> Cleanup 1
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()`.
@ -262,8 +264,8 @@ effect(function()
end)
end)
setCounter(1) --> Outer: 1, Inner: 1
setCounter(2) --> Outer: 2, Inner: 2
setCounter(1) -- Outer: 1, Inner: 1
setCounter(2) -- Outer: 2, Inner: 2
```
> [!NOTE]
@ -283,29 +285,29 @@ effect(function()
local runs = untracked(getRuns) + 1
setRuns(runs)
print(`Count is {getCounter()}, ran {runs} times`)
end) --> Count is 0, ran 1 times
end) -- Count is 0, ran 1 time
setCounter(10) --> Count is 10, ran 2 times
setCounter(10) -- Count is 10, ran 2 times
setRuns(10) -- No output
setCounter(20) --> Count is 20, ran 11 times
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()
local stopEffect
local stopScope = effectScope(function()
untracked(function()
disposeInner = effect(function()
stopEffect = effect(function()
return function()
print("Cleaned up inner effect")
print("Cleaned up untracked effect")
end
end)
end)
end)
disposeOuter() -- No output; outer effect did not track inner effect
disposeInner() --> Cleaned up inner effect
stopScope() -- No output, the scope did not track the effect
stopEffect() -- Cleaned up untracked effect
```
---
@ -318,18 +320,10 @@ Similar to `untracked()`, but does not prevent the parent effect from tracking n
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
print(`Count is {peek(getCounter)}`)
end) -- Count is 0
setCounter(1) -- No output; count was accessed in peek()
disposeOuter() --> Cleaned up inner effect
```
---
@ -372,7 +366,7 @@ local dispose = effectScope(function()
end)
end)
setCounter(1) --> Count 1 is 1, Count 2 is 1
setCounter(1) -- Count 1 is 1, Count 2 is 1
dispose()
setCounter(2) -- No output; effects got disposed
```
@ -392,9 +386,9 @@ local getCounter, setCounter = signal(0)
listen(getCounter, function(count, prevCount)
print(`Count is {count} (was {prevCount})`)
end) --> Count is 0 (was nil)
end) -- Count is 0 (was nil)
setCounter(1) --> Count is 1 (was 0)
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.
@ -413,7 +407,7 @@ subscribe(getCounter, function(count, prevCount)
print(`Count is {count} (was {prevCount})`)
end)
setCounter(1) --> Count is 1 (was 0)
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.
@ -432,9 +426,9 @@ observe(getItems, function(value, key)
return function()
print(`Removed {key}`)
end
end) --> Added a, Added b
end) -- Added a, Added b
setItems({ a = 0, c = 0 }) --> Removed b, Added c
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:
@ -456,9 +450,9 @@ local dispose = observe(getItems, function(value, key)
end)
end)
setItems({ a = 1, b = 0 }) --> Cleanup a = 0, a = 1
setItems({ a = 1 }) --> Cleanup b = 0
dispose() --> Cleanup a = 1
setItems({ a = 1, b = 0 }) -- Cleanup a = 0, a = 1
setItems({ a = 1 }) -- Cleanup b = 0
dispose() -- Cleanup a = 1
```
---
@ -476,7 +470,7 @@ local getUppercase = mapped(getList, function(value)
return string.upper(value)
end)
print(getUppercase()) --> { "A", "B", "C" }
print(getUppercase()) -- { "A", "B", "C" }
```
If the mapper returns two values, the second value is used as the new key:
@ -488,7 +482,7 @@ local getSwapped = mapped(getList, function(value, key)
return key, value
end)
print(getSwapped()) --> { a = 1, b = 2, c = 3 }
print(getSwapped()) -- { a = 1, b = 2, c = 3 }
```
---
@ -506,7 +500,7 @@ local dispose = effectScope(function()
end)
end)
dispose() --> Cleaned up
dispose() -- Cleaned up
```
---
@ -520,7 +514,7 @@ If the atom is called with 0 arguments, the atom returns the current value and s
```luau
local counter = atom(0)
print(counter()) --> 0
print(counter()) -- 0
counter(1)
counter(function(count)
return count + 1
@ -534,8 +528,8 @@ local max = atom(0, function(current, incoming)
return incoming <= current
end)
max(1) --> 1
max(-1) --> 1
max(1) -- 1
max(-1) -- 1
```
---
@ -553,7 +547,7 @@ effect(function()
setCounter(function(count)
return math.min(count + 1, 3)
end)
end) --> 0, 1, 2, 3
end) -- 0, 1, 2, 3
```
---
@ -568,15 +562,15 @@ local length = computed(function()
return #array()
end)
print(length()) --> 0
print(length()) -- 0
-- Direct mutation doesn't automatically trigger updates
table.insert(array(), 1)
print(length()) --> Still 0
print(length()) -- Still 0
-- Manually trigger updates
trigger(array)
print(length()) --> 1
print(length()) -- 1
```
You can also trigger multiple signals at once:
@ -596,7 +590,7 @@ trigger(function()
src2()
end)
print(total()) --> 2
print(total()) -- 2
```
---
@ -884,14 +878,14 @@ Charm v0.11 introduces _a lot_ of breaking changes, so below are some tips that
- Instead of creating client/server syncers, these modules now act like singletons. Sync APIs are called directly through `CharmSync.client`/`server`.
- [Read the updated docs for syncing state →](#client-server-sync)
3. The [`strict` and `frozen` flags](#globals) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior:
3. The [`strict` and `frozen` flags](#flags) are automatically enabled in Roblox Studio, so unsafe Charm code will start throwing errors if you didn't use the old `__DEV__` flag. The flags have the following behavior:
- `strict`: Yielding in effects, signals, and other critical Charm functions will throw an error
- `frozen`: Tables passed to signals are deeply frozen to strictly enforce data immutability and prevent accidental mutations
4. Nested effects automatically clean up when the parent effect re-runs or gets disposed. In other words, all effects created during the execution of another effect will be added as a "child" and clean up with the parent effect. This might cause issues in code that relied on the old behavior, where effects were detached from the parent.
- This feature applies to all reaction APIs, including the listener function in `subscribe()` and the observer function in `observe()`.
- Effects that should not be tracked by a parent effect/scope should be wrapped in [`untracked()`](#untrackedcallback).
- This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#globals) to `false`.
- This feature can introduce runtime bugs in migrated code. If you suspect this to be the cause, to help identify the issue, you can disable this feature by setting [`globals.trackInnerEffects`](#flags) to `false`.
> [!NOTE]
> An example of nested effects causing a bug is an old implementation of [`VideCharm.useAtom`](./packages/vide-charm/src/init.luau) that did not wrap the source update in `untracked()`. Because Vide effects run immediately after a source updates, Vide will notify components during the execution of the Charm effect in `useAtom`.