@rbxts/signals
TypeScript icon, indicating that this package has built-in type declarations

1.0.5 • Public • Published

@rbxts/signals

npm version GitHub license

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)); // 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:

  • 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())); // 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 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)); // 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 1

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)); // 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 4

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)); // will clear the interval when the owner is disposed
});
step(4) // will clear the interval, then run a new interval with 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)); // 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 100

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())); // Will print values from 0 to 100
target(100); // Starts tweening to 100, easing in and out, in 1 second

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())); // Will print values from 0 to 100
setTweened(100); // Starts tweening to 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); // 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 same

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); // 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 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:

Package Sidebar

Install

npm i @rbxts/signals

Weekly Downloads

10

Version

1.0.5

License

MIT

Unpacked Size

86.9 kB

Total Files

19

Last publish

Collaborators

  • rsman