diff --git a/README.md b/README.md index a31b977..559db68 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Charm

- Manage state with reactive signals + Fine-grained reactivity for Roblox
npm package →

@@ -18,18 +18,17 @@ -**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)`