Security News
Supply Chain Attack Detected in Solana's web3.js Library
A supply chain attack has been detected in versions 1.95.6 and 1.95.7 of the popular @solana/web3.js library.
@rbxts/charm
Advanced tools
Atomic state management for Roblox.
npm package →
Charm is an atomic state management library inspired by Jotai and Nanostores. Designed to be a more composable alternative to Reflex, Charm aims to address common criticisms of Rodux-like state containers to better address certain use cases.
⚛️ Manage state with atoms. Decompose state into small, distinct 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 — their memoization and dependencies are resolved for you.
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"
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
at the entry point of your project.
Enabling __DEV__
adds a few helpful features:
Better error handling for molecules, listeners, and batched functions:
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.
atom(state, options?)
Call atom
to create a state container with the value state
.
const nameAtom = atom("John");
const todosAtom = atom<string[]>([]);
state
: The value to assign to the atom initially.
optional options
: An object that configures the behavior of this atom.
equals
: An equality function to determine whether the state has changed. By default, strict equality (===
) is used.The atom
constructor returns an atom function with two possible operations:
function newTodo() {
nameAtom("Jane");
nameAtom(); // "Jane"
todosAtom((todos) => [...todos, "New todo"]);
}
subscribe(atom, listener)
Call subscribe
to listen for changes to an atom. Changes to the atom will immediately notify all subscribers, passing the new state and the previous state as arguments.
const nameAtom = atom("John");
subscribe(nameAtom, (name, prevName) => {
print(name);
});
You may also pass a molecule, or a function that derives a value from one or more atoms. The molecule will be memoized and only re-run when its dependencies change.
const getUppercase = () => nameAtom().upper();
subscribe(getUppercase, (name) => {
print(name);
});
atom
: An atom or molecule that you want to subscribe to. This can be an atom, or a function that reads from one or more atoms.
listener
: A function that will be called whenever the atom changes. The listener will receive the new state and the previous state as arguments.
subscribe
returns a cleanup function.
effect(callback)
Call effect
to declare a side effect that runs when any atom that it depends on changes. The effect will run immediately and whenever its dependencies change.
const nameAtom = atom("John");
effect(() => {
print(nameAtom());
return () => {
print("Changing name!");
};
});
callback
: The function that runs your effect. The function is called 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.effect
returns a cleanup function.
computed(molecule, options?)
Call computed
when you want to derive an expensive value from one or more atoms. The derived value will be memoized and only re-run when its dependencies change.
const todosAtom = atom<string[]>([]);
const mapToUppercase = computed(() => {
return todosAtom().map((todo) => todo.upper());
});
computed
is useful when you have multiple subscribers that depend on the same derived value. By memoizing the value, you can avoid re-calculating it for each subscriber.
molecule
: A function that reads from one or more atoms and returns a derived value.
optional options
: An object that configures the behavior of this atom.
computed
returns a read-only atom.
observe(atom, factory)
Call observe
to run the factory when a key is added to the atom's state. Your factory can return a cleanup function to run when the key is removed or the observer is disposed.
const todosAtom = atom<{ [Id in string]?: Todo }>({});
observe(todosAtom, (todo, key) => {
print(todo);
return () => {
print("Removing todo!");
};
});
atom
: An atom or molecule that you want to observe. This can be a primitive atom, or a function that reads from one or more atoms. The atom should return a dictionary or an array of objects.
factory
: A function called for each key in the atom's state. The factory will receive the entry as an argument and should return a cleanup function.
observe
returns a cleanup function.
mapped
to transform it into a keyed object before passing it to observe
.mapped(atom, mapper)
Call mapped
to transform the key-value pairs of an atom's state. The mapper function will be called for each key-value pair in the atom's state, and the result will be stored in a new atom.
const todosAtom = atom<Todo[]>([]);
const todosById = mapped(todosAtom, (todo, index) => {
return $tuple(todo, todo.id);
});
atom
: An atom or molecule that you want to map. This can be a primitive atom, or a function that reads from one or more atoms. The atom should return a dictionary or an array of objects.
mapper
: A function called for each key-value pair in the atom's state. The mapper will receive the entry as an argument and should return a new key-value pair. The value you return determines the type of the mapped atom:
If you return a tuple, the mapped atom returns a dictionary with the first element as the value and the second element as the key.
If you return a value without a key, the mapped atom returns an array of the given values in the order they were mapped.
If the first element is undefined
, the entry will be omitted from the mapped atom.
mapped
returns a read-only atom.
peek(value)
Call peek
to get the current state of an atom without tracking it as a dependency in functions like effect
and subscribe
.
const nameAtom = atom("John");
const ageAtom = atom(25);
effect(() => {
const name = nameAtom();
const age = peek(ageAtom);
});
value
: Any value. If the value is an atom, peek
will return the current state of the atom without tracking it. Otherwise, it will return the value as-is.
optional ...args
: Additional arguments to pass to the value if it is a function.
peek
returns the current state of the atom. If the value is not a function, it will return the value as-is.
batch(callback)
Call batch
to group multiple state changes into a single update. The callback will run immediately and listeners will only be notified once all changes have been applied.
const nameAtom = atom("John");
const ageAtom = atom(25);
batch(() => {
nameAtom("Jane");
ageAtom(26);
});
callback
: A function that makes multiple state changes. The changes will be batched together and listeners will only be notified once all changes have been applied.batch
does not return anything.
useAtom(atom, dependencies?)
Call useAtom
at the top-level of a React component to read from an atom.
import { useAtom } from "@rbxts/charm";
import { todosAtom } from "./todos-atom";
function TodosApp() {
const todos = useAtom(todosAtom);
// ...
}
By default, the atom is subscribed to once when the component initially mounts. Optionally, you may pass an array of dependencies to useAtom
if your atom should be memoized based on other values.
const todos = useAtom(searchTodos(filter), [filter]);
atom
: An atom or molecule that you want to read from. This can be an atom, or a function that reads from one or more atoms.
optional dependencies
: An array of values that the atom depends on. If the dependencies change, the atom will be re-subscribed to.
useAtom
returns the current state of the atom.
sync.server(options)
Call sync.server
to create a server sync object. The object handles sending state updates to clients at a specified interval, and hydrating clients with the initial state.
import { sync } from "@rbxts/charm";
const server = sync.server({
atoms: atomsToSync,
interval: 0,
preserveHistory: false,
});
server.connect((player, ...payloads) => {
remotes.syncState.fire(player, ...payloads);
});
remotes.requestState.connect((player) => {
server.hydrate(player);
});
options
: An object to configure the server syncer.
atoms
: An object containing the atoms to sync. The keys should match the keys on the client.
optional interval
: The interval at which to send state updates to clients. Defaults to 0
, meaning updates are sent on the next frame.
optional preserveHistory
: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If true
, the server sends multiple payloads instead of one. Defaults to false
for performance reasons.
sync.server
returns a server sync object. The sync object has the following methods:
server.connect(callback)
registers a callback to send state updates to clients. The callback will receive the player and the payload to send, and should send the payload to the client. The payload should not be mutated, so changes should be applied to a copy of the payload.
server.hydrate(player)
sends the initial state to a player. This calls the function passed to connect
with a payload containing the initial state.
By default, Charm omits the individual changes made to atoms between sync events (i.e. a counterAtom
set to 1
and then 2
will only send the final state of 2
). If you need to preserve a history of changes, set preserveHistory
to true
.
The server sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes sending the initial state, which is implemented via requestState
in the example above.
sync.client(options)
Call sync.client
to create a client sync object. The object will sync the client's copy of the state with the server's state.
import { sync } from "@rbxts/charm";
const client = sync.client({ atoms: atomsToSync });
remotes.syncState.connect((...payloads) => {
client.sync(...payloads);
});
remotes.requestState.fire();
options
: An object to configure the client syncer.
atoms
: An object containing the atoms to sync. The keys should match the keys on the server.sync.client
returns a client sync object. The sync object has the following methods:
client.sync(...payloads)
applies a state update from the server.requestState
in the example above.sync.isNone(value)
State patches represent the difference between the current state and next state, excluding unchanged values. However, this means both unchanged and removed values would be nil
in the patch. In these cases, Charm uses the None
marker to represent a removed value.
Call sync.isNone
to check if a value is None
.
import { sync } from "@rbxts/charm";
const server = sync.server({ atoms: atomsToSync });
server.connect((player, payload) => {
if (payload.type === "patch" && sync.isNone(payload.data.todosAtom?.eggs)) {
// 'eggs' will be removed from the client's todos atom
}
remotes.syncState.fire(player, payload);
});
value
: Any value. If the value is None
, sync.isNone
will return true
.sync.isNone
returns a boolean.
import { atom, subscribe } from "@rbxts/charm";
const counterAtom = atom(0);
// Create a derived atom that returns double the counter value
const doubleCounterAtom = () => counterAtom() * 2;
// Runs after counterAtom is updated and prints double the new value
subscribe(doubleCounterAtom, (value) => {
print(value);
});
counterAtom(1); // 2
counterAtom((count) => count + 1); // 4
import React from "@rbxts/react";
import { useAtom } from "@rbxts/charm";
import { counterAtom, incrementCounter } from "./counter-atom";
function Counter() {
const count = useAtom(counterAtom);
return (
<textlabel
Text={`Count: ${count}`}
Size={new UDim2(0, 100, 0, 50)}
Event={{
Activated: () => incrementCounter(),
}}
/>
);
}
Charm provides client and server objects for synchronizing state between the server and clients. Start by defining a module (or creating an object) exporting the atoms you want to sync:
export { counterAtom } from "./counter-atom";
export { writerAtom } from "./writer-atom";
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 that if preserveHistory
is true
, the server will send multiple payloads to the client, so the callback passed to connect
should accept a ...payloads
parameter. Otherwise, you only need to handle a single payload
parameter.
import { sync } from "@rbxts/charm";
import { remotes } from "./remotes";
import * as atoms from "./atoms";
const server = sync.server({ atoms });
// Broadcast a state update to a specific player
server.connect((player, ...payloads) => {
remotes.syncState.fire(player, ...payloads);
});
// Send initial state to a player upon request
remotes.requestState.connect((player) => {
server.hydrate(player);
});
Finally, on the client, create a client sync object and apply incoming state changes.
import { sync } from "@rbxts/charm";
import { remotes } from "./remotes";
import * as atoms from "./atoms";
const client = sync.client({ atoms });
// Listen for incoming state changes from the server
remotes.syncState.connect((...payloads) => {
client.sync(...payloads);
});
// Request initial state from the server
remotes.requestState.fire();
Charm is released under the MIT License.
FAQs
An atomic state management library for Roblox
The npm package @rbxts/charm receives a total of 231 weekly downloads. As such, @rbxts/charm popularity was classified as not popular.
We found that @rbxts/charm demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 0 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
A supply chain attack has been detected in versions 1.95.6 and 1.95.7 of the popular @solana/web3.js library.
Research
Security News
A malicious npm package targets Solana developers, rerouting funds in 2% of transactions to a hardcoded address.
Security News
Research
Socket researchers have discovered malicious npm packages targeting crypto developers, stealing credentials and wallet data using spyware delivered through typosquats of popular cryptographic libraries.