@rbxts/signals v1.0.5
@rbxts/signals
A powerful reactive system for Roblox TypeScript projects, inspired by S.js, SolidJS, Preact Signals, and Signals.dart. This library provides an efficient way to manage reactive state and computations in your Roblox games.
The library heavily depends on @rbxts/jsnatives for stores, and its utilities, like Object.isCallable is useful to check signals. Make sure to use all its features:
- loops using
Object.keys,Object.values,Object.entries. Object.isCallableto check if an element is callable (functions or table with__callmetamethod).Object.isArrayto check if an element is an array.JSON.stringifyandJSON.parseto log, store, send values.
This way, proxies or any other non-lua elements will become almost invisible to the developper.
Table of Contents
Installation
Currently available through GitHub (to get the latest commited build, ensure using commit hash for stability):
npm install @rbxts/signals@github:RsMan-Dev/rbxts-signalsVia NPM:
npm install @rbxts/signalsKey Concepts
Signals
Signals are reactive primitives that hold a value and notify subscribers when the value changes. They are the foundation of the reactive system.
Owners
Owners are the context in which computations are executed. They manage the lifecycle of computations and their dependencies.
Computations
Computations are functions that depend on signals. They automatically re-run when their dependencies change, allowing for reactive updates. Computations can be nested and will track only their direct dependencies.
Batching
Batching allows multiple signal updates to be processed together, reducing unnecessary recomputations and improving performance. A batch freezes the updates, so signals are only updated when batching is finished.
API Reference
➤ createRoot
Creates a new owner and runs a function within that owner. Returns the result of the function.
⚠️ Warning: Unlike other functions that manipulate owner,
createRootdoes not auto-dispose when its parent owner is disposed. If you want automatic disposal, useonDispose.
function createRoot<T>(fn: (dispose: () => void) => T): T;Example:
let dispose: Owner | undefined;
createRoot((disposeFn) => {
dispose = disposeFn;
const count = createSignal(0);
const doubleCount = createMemo(() => count() * 2);
createEffect(() => print(doubleCount.val)); // prints 0
count.val++; // prints 2
onDispose(() => print("dispose called")); // prints "dispose called"
});
dispose?.(); // prints "dispose called", inner effects are disposed➤ createEffect
Creates a new computation that runs whenever its dependencies change.
⚠️ Warning:
- The computation result is not memoized
- Any computation made outside an owner will never be disposed, causing memory leaks
ℹ️ Note: Effects are batched, no need to use
batch()inside an effect.
function createEffect<T>(
fn: (value: T | undefined) => T,
value?: T
): () => T;➤ createSignal
Creates a new signal with an initial value.
ℹ️ Note:
Signalis a table, wrapped using metatable to provide all methods and direct call, sotypeof(signal)will returntable, useObject.isCallableto check if element can be called.- as
Signalis a table, if you make no usage of utilities, you can unwrap the metatable likeconst {accessor: count, set: countSet} = createSignal(0), the table will get garbage collected, and you will only have essential methods.- when signal is lazy, it will be initialized on first use, so it can be used in class properties.
function createSignal<T>(
value: T,
options?: {
eq?: ((a: T, b: T) => boolean) | false,
lazy?: boolean
}
): Signal<T>;
type Signal<T> = {
(): T;
(val: T): T;
val: T;
set: (fn: (val: T) => T) => T;
peek: T;
accessor: () => T;
}Example:
const count = createSignal(0);
const count2 = createSignal(0, { eq: false });
createEffect(() => print(count())); // prints 0, count is initialized
createEffect(() => print(count2.val)); // prints 0, count2 is initialized
count.val++; // prints 1
count(1); // prints nothing, as it's the same value
count.set((val) => val + 1); // prints 2
count2.set((val) => val + 1); // prints 1
count(1); // prints 1, as eq is false➤ createMemo
Creates a memoized computation that only re-runs when its dependencies change.
⚠️ Warning: As
createMemocreates a computation, it has the same warnings ascreateEffect.ℹ️ Note:
createMemois also batched, no need to usebatch()inside a memo, if any external modification is made in the effect.createMemois lazy, so it will be initialized on first use, so it can be used in class properties, the owner used will be the one that was set when createMemo was called, or the owner that is set when the memo is used, so make sure the owner is set where you want it, to avoid unpredicted cleanups of the memo effect.createMemois a readonly signal, likeSignal, it's a table, so the same warnings apply, can be unwrapped likeconst memo = createMemo(() => count()).accessor, the table will get garbage collected, and you will only have essential method.
function createMemo<T>(
fn: (v: T | undefined) => T,
value: T | undefined,
options?: {
eq?: ((a: T, b: T) => boolean) | false,
lazy?: boolean
}
): ReadonlySignal<T>;
type ReadonlySignal<T> = {
(): T;
readonly val: T;
peek: T;
accessor: () => T;
}Example:
const count = createSignal(0);
const doubleCount = createMemo(() => count() * 2);
createEffect(() => print(doubleCount.val)); // prints 0
count.val++ // prints 2
count(1) // prints nothing
count.set((val) => val + 1) // prints 4➤ on
Utility to isolate tracking for one function and treatment to another one, mostly used inside computations. Its option defer is used to defer the treatment of the function, so it will be called after the first update of any of its dependencies, the initialization will not run the treatment.
function on<I, T>(
on: () => I,
fn: (r: I, v?: T) => T,
options?: {
defer?: boolean
}
): (v?: T) => T;Usage:
const count = createSignal(0);
const count2 = createSignal(0);
const doubleCount2 = createMemo(on(() => count2(), () => count2() + count()));
createEffect(on(() => doubleCount2(), () => print(doubleCount2.val, count.val), {defer: true})); // prints nothing
count.val++ // prints nothing, as count() is not tracked
count2.val++ // prints 2 1runWithOwner and getOwner
getOwner returns the current owner, and runWithOwner allows running a function with a specific owner. Useful on async functions, or when you want to control the owner of a computation. Owner.apply has the same effect as runWithOwner.
function getOwner(): Owner | undefined;
function runWithOwner<T>(owner: Owner, fn: () => T): T;Usage:
const owner = getOwner();
someAsyncInitializer().then(() => {
const count = createSignal(0);
runWithOwner(owner, () => {
const doubleCount = createMemo(() => count() * 2);
createEffect(() => print(doubleCount.val)); // prints 0
count.val ++ // prints 2
});
owner.apply(() => createEffect(() => print(count.val)));// prints 1
count.val ++ // prints 4, and 2
createEffect(() => print(count.val)); // warns in console that a computation without an owner will never be disposed, then prints 2
});batch
Groups multiple signal updates into a single batch, preventing unnecessary recomputations.
function batch<T>(fn: () => T): T;Usage:
const count = createSignal(0);
const doubleCount = createMemo(() => count() * 2);
batch(() => {
count.val++; // will add 1 to count.val (currently 0)
count.val++; // will add 1 to count.val (currently 0, because of batching, that stops the signal to update)
count.set((val) => val + 1); // will add 1 to count.val (currently 1, set can get the pending value)
}); // prints 4onDispose
Registers a callback that will be called when the current owner is disposed.
function onDispose(fn: () => void): void;Usage:
const count = createSignal(0);
const step = createSignal(2);
createEffect(() => {
const currentStep = step();
const interval = setInterval(() => {
count.val+=currentStep;
}, 1000);
onDispose(() => clearInterval(interval)); // will clear the interval when the owner is disposed
});
step(4) // will clear the interval, then run a new interval with step 4createContext
Creates a context object for passing data down the graph. CountContext.Provider exists for jsx compatibility. Contexts can pass any value (signals, tables, classes, etc), and can be used with useContext to get the value from the context.
function createContext<T>(defaultValue: T): Context<T>;Usage:
const CountContext = createContext(0);
createEffect(() => {
CountContext.apply(1, () => {
print(useContext(CountContext)); // prints 1
createEffect(() => {
print(useContext(CountContext)); // prints 1
const owner = getOwner(); // keep the owner to use it in async gaps
setTimeout(() => {
owner.apply(() => {
print(useContext(CountContext)); // prints 1 after 1 second
});
}, 1000);
});
});
print(useContext(CountContext)); // prints 0
});Primitives API Reference
➤ Animation Primitives
keyframes
Creates a keyframe animation function that interpolates between multiple keyframes
ℹ️ Note:
- The first keyframe is the starting point, defining easing here will have no effect.
- Easing functions can be keyframes too, to make the animation more complex.
function keyframes(
...keys: { at: number; value: number, easing?: (t: number) => number }[]
): (t: number) => number;Example:
const animation = keyframes(
{ at: 0, value: 0 }, // starting point, defining easing here will have no effect
{ at: 0.5, value: 100, easing: curves.easeInOut }, // Between 0 and 0.5, the animation will ease in and out from 0 to 100
{ at: 1, value: 0 } // Between 0.5 and 1, the animation will linearly go from 100 to 0
);
// Use the animation function with a progress value (0 to 1)
const value = animation(0.5); // Returns 100curves
A collection of basic easing functions for animations:
const curves = {
linear: (t: number) => number,
easeIn: (t: number) => number,
easeOut: (t: number) => number,
easeInOut: (t: number) => number,
bounce: (t: number) => number,
bounceIn: (t: number) => number,
bounceOut: (t: number) => number,
bounceInOut: (t: number) => number,
elastic: (t: number) => number,
elasticIn: (t: number) => number,
elasticOut: (t: number) => number,
elasticInOut: (t: number) => number,
back: (t: number) => number,
backIn: (t: number) => number,
backOut: (t: number) => number,
backInOut: (t: number) => number,
steps: (steps: number, direction: 'start' | 'end' | 'both' = 'end') => (t: number) => number,
cubicBezier: (p0: Point, p1: Point, p2: Point, p3: Point) => (t: number) => number
};
interface Point {
x: number;
y: number;
}➤ Tween Primitives
createTween
Creates a tween signal that animates a value from its current value to the target value over a specified duration. Easing can be keyframes too.
function createTween(
target: () => number,
{ ease = (t: number) => t, duration = 100 }: TweenProps = {}
): ReadonlySignal<number>;Example:
const target = createSignal(0);
const tween = createTween(target, {
duration: 1000,
ease: curves.easeInOut
});
createEffect(() => print(tween())); // Will print values from 0 to 100
target(100); // Starts tweening to 100, easing in and out, in 1 secondcreateTweened
Creates a tweened signal that animates a value from its current value to the next value when its target changes.
function createTweened(
value: number,
props?: TweenProps
): [ReadonlySignal<number>, (value: number) => void];Example:
const [tweened, setTweened] = createTweened(0, {
duration: 1000,
ease: curves.easeInOut
});
createEffect(() => print(tweened())); // Will print values from 0 to 100
setTweened(100); // Starts tweening to 100Store API Reference
➤ Mutable Store
createMutable
Creates a mutable store that will transform objects in a big graph of reactive signals, so any change to the object will trigger fine-grained updates. Updating a whole object using the same type of object will patch the object, avoiding replacing the whole object.
function createMutable<T extends object>(obj: T): T;Example:
const store = createMutable({
user: {
name: "John",
age: 30,
friends: ["Alice", "Bob"]
}
});
createEffect(() => {
print(store.user.name); // Will print when name or user structure changes
print(store.user.friends[0]); // Will print when first friend or friends array structure, or user structure changes
});
store.user.name = "Jane"; // Triggers effect
store.user.friends.push("Charlie"); // Triggers effect
store.user = {
name: "Jane",
age: 30,
friends: ["Alice", "Bob"]
}; // Triggers only the second effect, as user doesn't change its structure, and its name remains the sameunwrap
Unwraps a mutable store to get a cloned mirror of the original object, so any change to the original object will not trigger effects.
function unwrap<T>(obj: T, untracks = true): T;Example:
const store = createMutable({ value: 1 });
const raw = unwrap(store); // Returns { value: 1 }withWrap
If the object was wrapped in a mutable before, it will find the wrap and apply it again.
function withWrap<T extends object>(obj: T): T;withoutWrap
If the object was wrapped in a mutable before, it will return the original object without any wrap.
function withoutWrap<T extends object>(obj: T, untracked = true): T;ℹ️ Note:
- Mutable stores automatically track nested objects and arrays
- Changes to nested values trigger updates in computations
- Use
unwrapto get a mirror of the original object, so any change to the original object will not affect the value returned byunwrap.- Use
withWrapandwithoutWrapto control the tracking when manipulating the object.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Contributing
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
Acknowledgments
This library was inspired by: