littensy / charm

πŸ€ Atomic state management for Roblox
https://npmjs.org/package/@rbxts/charm
MIT License
52 stars 5 forks source link
roblox roblox-ts state-management typescript

Logo

Charm

Atomic state management for Roblox.
npm package β†’

![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/littensy/charm/ci.yml?style=for-the-badge&branch=main&logo=github) [![NPM Version](https://img.shields.io/npm/v/@rbxts/charm.svg?style=for-the-badge&logo=npm)](https://www.npmjs.com/package/@rbxts/charm) [![GitHub License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)

Charm is an atomic state management library inspired by Jotai and Nanostores. Store your immutable state in atoms, and write intuitive functions that read, write, and subscribe to state.

See an example of Charm's features in this example repository.

πŸ€ Features


πŸ“¦ Setup

Install Charm for roblox-ts using your package manager of choice.

npm install @rbxts/charm
yarn add @rbxts/charm
pnpm add @rbxts/charm

Alternatively, add littensy/charm to your wally.toml file.

[dependencies]
Charm = "littensy/charm@VERSION"

πŸ› Debugging

Charm provides a debug mode to help you identify potential bugs in your project. To enable debug mode, set the global _G.__DEV__ flag to true before importing Charm.

Enabling __DEV__ adds a few helpful features:

Enabling debug mode in unit tests, storybooks, and other development environments can help you catch potential issues early. However, remember to turn off debug mode in production to avoid the performance overhead.


πŸ“š Reference

atom(state, options?)

Atoms are the building blocks of Charm. They are functions that hold a single value, and calling them can read or write to that value. Atoms, or any function that reads from atoms, can also be subscribed to.

Call atom to create a state container initialized with the value state.

local nameAtom = atom("John")
local todosAtom: Atom<{ string }> = atom({})

Parameters

Returns

The atom constructor returns an atom function with two possible operations:

  1. Read the state. Call the atom without arguments to get the current state.
  2. Set the state. Pass a new value or an updater function to change the state.
local function newTodo()
    nameAtom("Jane")

    todosAtom(function(todos)
        todos = table.clone(todos)
        table.insert(todos, "Buy milk")
        return todos
    end)

    print(nameAtom()) --> "Jane"
end

subscribe(callback, listener)

Call subscribe to listen for changes in an atom or selector function. When the function's result changes, subscriptions are immediately called with the new state and the previous state.

local nameAtom = atom("John")

subscribe(nameAtom, function(name, prevName)
    print(name)
end)

nameAtom("Jane") --> "Jane"

You may also pass a selector function that calls other atoms. The function will be memoized and only runs when its atom dependencies update.

local function getUppercase()
    return string.upper(nameAtom())
end

subscribe(getUppercase, function(name)
    print(name)
end)

nameAtom("Jane") --> "JANE"

Parameters

Returns

subscribe returns a cleanup function.


effect(callback)

Call effect to track state changes in all atoms read within the callback. The callback will run once to retrieve its dependencies, and then again whenever they change. Your callback may return a cleanup function to run when the effect is removed or about to re-run.

local nameAtom = atom("John")

effect(function()
    print(nameAtom())
    return function()
        print("Cleanup function called!")
    end
end)

Because effect implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use peek to read from atoms without tracking them as dependencies.

Parameters

Returns

effect returns a cleanup function.


computed(callback, options?)

Call computed when you want to derive a new atom from one or more atoms. The callback will be memoized, meaning that subsequent calls to the atom return a cached value that is only re-calculated when the dependencies change.

local todosAtom: Atom<{ string }> = atom({})
local mapToUppercase = computed(function()
    local result = table.clone(todosAtom())
    for key, todo in result do
        result[key] = string.upper(todo)
    end
    return result
end)

Because computed implicitly tracks all atoms read within the callback, it might be useful to exclude atoms that should not trigger a re-run. You can use peek to read from atoms without tracking them as dependencies.

This function is also useful for optimizing effect calls that depend on multiple atoms. For instance, if an effect derives some value from two atoms, it will run twice if both atoms change at the same time. Using computed can group these dependencies together and avoid re-running side effects.

Parameters

Returns

computed returns a read-only atom.


observe(callback, factory)

Call observe to create an instance of factory for each key present in a dictionary or array. Your factory can return a cleanup function to run when the key is removed or the observer is cleaned up.

[!NOTE] Because observe tracks the lifetime of each key in your data, your keys must be unique and unchanging. If your data is not keyed by unique and stable identifiers, consider using mapped to transform it into a keyed object before passing it to observe.

local todosAtom: Atom<{ [string]: Todo }> = atom({})

observe(todosAtom, function(todo, key)
    print(`Added {key}: {todo.name}`)
    return function()
        print(`Removed {key}`)
    end
end)

Parameters

Returns

observe returns a cleanup function.


mapped(callback, mapper)

Call mapped to transform the keys and values of your state. The mapper function will be called for each key-value pair in the atom's state, and the new keys and atoms will be stored in a new atom.

local todosAtom: Atom<{ Todo }> = atom({})
local todosById = mapped(todosAtom, function(todo, index)
    return todo, todo.id
end)

Parameters

Returns

mapped returns a read-only atom.


peek(value, ...)

Call peek to call a function without tracking it as the dependency of an effect or a selector function.

local nameAtom = atom("John")
local ageAtom = atom(25)

effect(function()
    local name = nameAtom()
    local age = peek(ageAtom)
end)

Parameters

Returns

peek returns the result of the given function. If the value is not a function, it will return the value as-is.


batch(callback)

Call batch to defer state changes until after the callback has run. This is useful when you need to make multiple changes to the state and only want listeners to be notified once.

local nameAtom = atom("John")
local ageAtom = atom(25)

batch(function()
    nameAtom("Jane")
    ageAtom(26)
end)

Parameters

Returns

batch does not return anything.


πŸ“¦ React

Setup

Install the React bindings for Charm using your package manager of choice.

npm install @rbxts/react-charm
yarn add @rbxts/react-charm
pnpm add @rbxts/react-charm
[dependencies]
ReactCharm = "littensy/react-charm@VERSION"

useAtom(callback, dependencies?)

Call useAtom at the top-level of a React component to read from an atom or selector. The component will re-render when the value changes.

local todosAtom = require(script.Parent.todosAtom)

local function Todos()
    local todos = useAtom(todosAtom)
    -- ...
end

If your selector depends on the component's state or props, remember to pass them in a dependency array. This prevents skipped updates when an untracked parameter of the selector changes.

local todos = useAtom(function()
    return searchTodos(props.filter)
end, { props.filter })

Parameters

Returns

useAtom returns the current state of the atom.


πŸ“¦ Vide

Setup

Install the Vide bindings for Charm using your package manager of choice.

npm install @rbxts/vide-charm
yarn add @rbxts/vide-charm
pnpm add @rbxts/vide-charm
[dependencies]
VideCharm = "littensy/vide-charm@VERSION"

useAtom(callback)

Call useAtom in any scope to create a Vide source that returns the current state of an atom or selector.

local todosAtom = require(script.Parent.todosAtom)

local function Todos()
    local todos = useAtom(todosAtom)
    -- ...
end

Parameters

Returns

useAtom returns a Vide source.


πŸ“— Charm Sync

Setup

The Charm Sync package provides server-client synchronization for your Charm atoms. Install it using your package manager of choice.

npm install @rbxts/charm-sync
yarn add @rbxts/charm-sync
pnpm add @rbxts/charm-sync
[dependencies]
CharmSync = "littensy/charm-sync@VERSION"

server(options)

Call server to create a server sync object. This synchronizes every client's atoms with the server's state by sending partial patches that the client merges into its state.

local syncer = CharmSync.server({
    atoms = atomsToSync,     -- A dictionary of the atoms to sync, matching the client's
    interval = 0,            -- The minimum interval between state updates
    preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower)
    serializeArrays = true,  -- Safety measures for arrays, should be false when using ByteNet or Zap
})

-- Sends state updates to clients when a synced atom changes.
-- Omitting sensitive information and data serialization can be done here.
syncer:connect(function(player, ...)
    remotes.syncState:fire(player, ...)
end)

-- Sends the initial state to a player upon request. This should fire when a
-- player joins the game.
remotes.requestState:connect(function(player)
    syncer:hydrate(player)
end)

Parameters

Returns

server returns an object with the following methods:

Caveats


client(options)

Call client to create a client sync object. This synchronizes the client's atoms with the server's state by merging partial patches sent by the server into each atom.

local syncer = CharmSync.client({
    atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the server's
    ignoreUnhydrated = true, -- Whether to ignore state updates before the initial update
})

-- Applies state updates from the server to the client's atoms.
-- Data deserialization can be done here.
remotes.syncState:connect(function(...)
    syncer:sync(...)
end)

-- Requests the initial state from the server when the client joins the game.
-- Before this runs, the client uses the atoms' default values.
remotes.requestState:fire()

Parameters

Returns

client returns an object with the following methods:

Caveats


isNone(value)

Call isNone to check if a value is None. Charm's partial state patches omit values that did not change between sync events, so to mark keys for deletion, Charm uses the None marker.

This function can be used to check whether a value is about to be removed from an atom.

local syncer = CharmSync.server({ atoms = atomsToSync })

syncer:connect(function(player, payload)
    if
        payload.type === "patch"
        and payload.data.todosAtom
        and CharmSync.isNone(payload.data.todosAtom.eggs)
    then
        -- 'eggs' will be removed from the client's todo list
    end
    remotes.syncState.fire(player, payload)
end)

Parameters

Returns

isNone returns a boolean.


πŸš€ Examples

Counter atom

local counterAtom = atom(0)

-- Create a selector that returns double the counter value
local function doubleCounter()
    return counterAtom() * 2
end

-- Runs after counterAtom is updated and prints double the new value
subscribe(doubleCounter, function(value)
    print(value)
end)

counterAtom(1) --> 2
counterAtom(function(count)
    return count + 1
end) --> 4

React component

local counter = require(script.Parent.counter)
local counterAtom = counter.counterAtom
local incrementCounter = counter.incrementCounter

local function Counter()
    local count = useAtom(counterAtom)

    return React.createElement("TextButton", {
        [React.Event.Activated] = incrementCounter,
        Text = `Count: {count}`,
        Size = UDim2.new(0, 100, 0, 50),
    })
end

Vide component

local counter = require(script.Parent.counter)
local counterAtom = counter.counterAtom
local incrementCounter = counter.incrementCounter

local function Counter()
    local count = useAtom(counterAtom)

    return create "TextButton" {
        Activated = incrementCounter,
        Text = function()
            return `Count: {count()}`
        end,
        Size = UDim2.new(0, 100, 0, 50),
    }
end

Server-client sync

Charm is designed for both client and server use, but there are often cases where the client needs to reference state that lives on the server. The CharmSync package provides a way to synchronize atoms between the server and its clients using remote events.

Start by creating a set of atoms to sync between the server and clients. Export these atoms from a module to be shared between the server and client:

-- atoms.luau
local counter = require(script.Parent.counter)
local todos = require(script.Parent.todos)

return {
    counterAtom = counter.counterAtom,
    todosAtom = todos.todosAtom,
}

Then, on the server, create a server sync object and pass in the atoms to sync. Use remote events to broadcast state updates and send initial state to clients upon request.

[!NOTE] If preserveHistory is true, the server will send multiple payloads to the client, so the callback passed to connect should accept a variadic ...payloads parameter. Otherwise, you only need to handle a single payload parameter.

-- sync.server.luau
local atoms = require(script.Parent.atoms)

local syncer = CharmSync.server({ atoms = atoms })

-- Sends state updates to clients when a synced atom changes.
-- Omitting sensitive information and data serialization can be done here.
syncer:connect(function(player, payload)
    remotes.syncState.fire(player, payload)
end)

-- Sends the initial state to a player upon request. This should fire when a
-- player joins the game.
remotes.requestState:connect(function(player)
    syncer:hydrate(player)
end)

Finally, on the client, create a client sync object and use it to apply incoming state changes.

-- sync.client.luau
local atoms = require(script.Parent.atoms)

local syncer = CharmSync.client({ atoms = atoms })

-- Applies state updates from the server to the client's atoms.
remotes.syncState:connect(function(payload)
    syncer:sync(payload)
end)

-- Requests the initial state from the server when the client joins the game.
-- Before this runs, the client uses the atoms' default values.
remotes.requestState:fire()

Charm is released under the MIT License.

[![MIT License](https://img.shields.io/github/license/littensy/charm?style=for-the-badge)](LICENSE.md)