@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.isCallable
to check if an element is callable (functions or table with __call
metamethod).
Object.isArray
to check if an element is an array.
JSON.stringify
and JSON.parse
to 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-signals
Via NPM:
npm install @rbxts/signals
Key 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, createRoot
does not auto-dispose when its parent owner is disposed. If you want automatic disposal, use onDispose
.
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));
count.val++;
onDispose(() => print("dispose called"));
});
dispose?.();
➤ 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:
Signal
is a table, wrapped using metatable to provide all methods and direct call, so typeof(signal)
will return table
, use Object.isCallable
to check if element can be called.
- as
Signal
is a table, if you make no usage of utilities, you can unwrap the metatable like const {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()));
createEffect(() => print(count2.val));
count.val++;
count(1);
count.set((val) => val + 1);
count2.set((val) => val + 1);
count(1);
➤ createMemo
Creates a memoized computation that only re-runs when its dependencies change.
⚠️ Warning: As createMemo
creates a computation, it has the same warnings as createEffect
.
ℹ️ Note:
createMemo
is also batched, no need to use batch()
inside a memo, if any external modification is made in the effect.
createMemo
is 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.
createMemo
is a readonly signal, like Signal
, it's a table, so the same warnings apply, can be unwrapped like const 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));
count.val++
count(1)
count.set((val) => val + 1)
➤ 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}));
count.val++
count2.val++
runWithOwner 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));
count.val ++
});
owner.apply(() => createEffect(() => print(count.val)));
count.val ++
createEffect(() => print(count.val));
});
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++;
count.val++;
count.set((val) => val + 1);
});
onDispose
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));
});
step(4)
createContext
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));
createEffect(() => {
print(useContext(CountContext));
const owner = getOwner();
setTimeout(() => {
owner.apply(() => {
print(useContext(CountContext));
});
}, 1000);
});
});
print(useContext(CountContext));
});
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 },
{ at: 0.5, value: 100, easing: curves.easeInOut },
{ at: 1, value: 0 }
);
const value = animation(0.5);
curves
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()));
target(100);
createTweened
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()));
setTweened(100);
Store 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);
print(store.user.friends[0]);
});
store.user.name = "Jane";
store.user.friends.push("Charlie");
store.user = {
name: "Jane",
age: 30,
friends: ["Alice", "Bob"]
};
unwrap
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);
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
unwrap
to get a mirror of the original object, so any change to the original object will not affect the value returned by unwrap
.
- Use
withWrap
and withoutWrap
to 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: