Charm
🍀 Atomic state management for Roblox
Install / Use
/learn @littensy/CharmREADME
Charm is an atomic and immutable state management library, inspired by Jotai and Nanostores. Store your state in atoms, and write your own functions to read, write, and observe state.
See an example of Charm's features in this example repository.
🍀 Features
-
⚛️ Manage state with atoms. Decompose state into independent containers called atoms, as opposed to combining them into a single store.
-
💪 Minimal, yet powerful. Less boilerplate — write simple functions to read from and write to state.
-
🔬 Immediate updates. Listeners run asynchronously by default, avoiding the cascading effects of deferred updates and improving responsiveness.
-
🦄 Like magic. Selector functions can be subscribed to as-is — with implicit dependency tracking, atoms are captured and memoized for you.
📦 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:
-
Better error handling for selectors, subscriptions, and batched functions:
- Errors provide the function's name and line number.
- Yielding in certain functions will throw an error.
-
Server state is validated for remote event limitations before being passed to the client.
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
-
state: The value to assign to the atom initially. -
optional
options: An object that configures the behavior of this atom.- optional
equals: An equality function to determine whether the state has changed. By default, strict equality (==) is used.
- optional
Returns
The atom constructor returns an atom function with two possible operations:
- Read the state. Call the atom without arguments to get the current state.
- 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")
local cleanup = 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
local cleanup = subscribe(getUppercase, function(name)
print(name)
end)
nameAtom("Jane") --> "JANE"
Parameters
-
callback: The function to subscribe to. This may be an atom or a selector function that depends on an atom. -
listener: The listener is called when the result of the callback changes. It receives the new state and the previous state as arguments.
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")
local cleanup = 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
callback: The function to track for state changes. The callback will run once to retrieve its dependencies, and then again whenever they change.
Returns
effect returns a cleanup function.
Caveats
- If your effect should disconnect itself, use the
cleanupargument. Because effects run immediately, your effect may run before acleanupfunction is returned. To disconnect an effect from the inside, use the argument passed to your effect instead:effect(function(cleanup) if condition() then cleanup() end end)
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
-
callback: A function that returns a new value depending on one or more atoms. -
optional
options: An object that configures the behavior of this atom.
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
observetracks 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 usingmappedto transform it into a keyed object before passing it toobserve.
local todosAtom: Atom<{ [string]: Todo }> = atom({})
local cleanup = observe(todosAtom, function(todo, key)
print(`Added {key}: {todo.name}`)
return function()
print(`Removed {key}`)
end
end)
Parameters
-
callback: An atom or selector function that returns a dictionary or an array of values. When a key is added to the state, the factory will be called with the new key and its initial value. -
factory: A function that will be called whenever a key is added or removed from the atom's state. The callback will receive the key and the entry's initial value as arguments, and may return a cleanup function.
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
-
callback: The function whose result you want to map over. This can be an atom or a selector function that reads from atoms. -
mapper: The mapper is called for each key in your state. Given the current value and key, it should return a new corresponding value and key:- Return a single value to map the table's original key to a new value.
- Return two values, the first being the value and the second being the key, to update both keys and values.
- Return
nilfor the value to remove the key from the resulting table.
Returns
mapped returns a read-only atom.
peek(value, ...)
Call peek to call a function without tracking
Related Skills
node-connect
350.1kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
109.9kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
Writing Hookify Rules
109.9kThis skill should be used when the user asks to "create a hookify rule", "write a hook rule", "configure hookify", "add a hookify rule", or needs guidance on hookify rule syntax and patterns.
review-duplication
100.4kUse this skill during code reviews to proactively investigate the codebase for duplicated functionality, reinvented wheels, or failure to reuse existing project best practices and shared utilities.
