@maverick-js/signals

repository·main·Indexed 21 days ago

https://github.com/maverick-js/signals

A lightweight (~1kB) TypeScript reactivity library providing a signal-based API for managing state, computed properties, and side effects. It features lazy evaluation, batched updates via a microtask scheduler, and environment agnostic support for browsers and Node.js. Key primitives include `signal` for state, `computed` for derived values, and `effect` for side effects, with scope management provided by the `root` function to prevent memory leaks.

Tokens
7.6K
Snippets
30
Records
36
Agent score
73%

What's inside @maverick-js/signals

  1. What are Signals and how do they work?

    main

    Signals is a lightweight (~1kB minzipped) reactivity API designed to provide reactive observables for UI libraries. It follows a "lazy principle" similar to Svelte, ensuring that work is only performed when necessary.

    Core Features:

    • Reactive Observables: Store state and create computed properties.
    • Lazy Evaluation: Computations only re-run when their dependencies change.
    • Batched Updates: Uses a microtask scheduler to batch updates.
    • Change Detection: Only triggers updates when a value has actually changed.
    • Environment Agnostic: Works in both browsers and Node.js.
    • Strongly Typed: Built with TypeScript for full type safety.
  2. Manage computation lifecycles with `root`

    main

    Computations (like computed or effect) are typically child computations that are destroyed when their parent scope is destroyed. If you create a computation without a parent, it becomes an 'orphan' that lives in memory until garbage collected. To avoid orphans and easily manage groups of computations, use root. It creates a scope and provides a dispose function that cleans up all inner computations at once.

    import { root, signal, computed, effect } from '@maverick-js/signals';
    
    root((dispose) => {
      const $a = signal(10);
      const $b = computed(() => $a());
    
      effect(() => console.log($b()));
    
      // Disposes of `$a`, `$b`, and `effect`.
      dispose();
    });
  3. Choose between computedMap and computedKeyedMap

    main

    Choosing the right mapping helper depends on how your data changes:

    FeaturecomputedMapcomputedKeyedMap
    Primary KeyIndex (Position)Item Reference (Identity)
    BehaviorIf an item moves, it is treated as a new value at a new index.If an item moves, its existing mapping is moved to the new index.
    Best ForPrimitives (strings, numbers) or stable lists.Objects or lists that undergo reordering/sorting.
    Mapping Signature(value: ReadSignal<Item>, index: number) => MappedItem(value: Item, index: ReadSignal<number>) => MappedItem
  4. How scope management and context work

    main

    Reactivity in this library is organized into a tree of Scopes.

    1. Hierarchy: When a new computation (signal, computed, or effect) is created, it is automatically appended as a child to the currentScope.
    2. Context: Each scope can hold a _context object. When getContext is called, the system walks up the parent chain ([SCOPE]) until it finds the requested key. setContext allows you to provide data to an entire branch of the reactivity tree.
    3. Disposal: When a scope is disposed via dispose(), it recursively disposes of all its children and executes all registered onDispose handlers. This ensures a clean teardown of the reactive graph.
    4. Error Propagation: Errors thrown during computation are caught by the scope's _handlers. If a handler doesn't resolve the error, it bubbles up to the parent scope.
  5. Manage lifecycle with Scope and Dispose

    main

    The Scope interface defines the hierarchical lifecycle management in the signals library. Scopes allow you to group related signals, computations, and effects, ensuring they are cleaned up together.

    • append(scope: Scope): Adds a child scope to the current scope.
    • dispose(): Terminates the scope and all its children, triggering their respective disposal logic.

    An object implementing Disposable or Dispose can be used to define custom cleanup logic that runs when a scope is disposed.

  6. Basic usage of signals, computed, and effects

    main

    You can create stateful signals, derive values using computed, and react to changes using effect. Updates are batched via microtasks, so you may need to call tick() to flush the queue synchronously if you need immediate execution in your logic.

    To manage the lifecycle of your reactive graph, wrap your logic in a root function, which provides a dispose function to clean up all signals and effects created within that scope.

    import { root, signal, computed, effect, tick } from '@maverick-js/signals';
    
    root((dispose) => {
      // Create signals
      const $m = signal(1);
      const $x = signal(1);
      const $b = signal(0);
    
      // Create a computed value: $y = $m * $x + $b
      const $y = computed(() => $m() * $x() + $b());
    
      // Create an effect that runs when $y changes
      const stop = effect(() => {
        console.log($y());
    
        // Cleanup function called when effect ends or is disposed
        return () => {};
      });
    
      $m.set(10); 
    
      // Flush queue synchronously so effect runs immediately
      tick();
    
      $b.set((prev) => prev + 5);
    
      tick();
    
      // Stop the specific effect
      stop();
    
      // Dispose of all signals/effects inside this root
      dispose();
    });
  7. Check signal types with `isReadSignal` and `isWriteSignal`

    main

    Use these utility functions to identify the capabilities of a signal:

    • isReadSignal(val): Returns true if the value is a signal that can be read (including computed and readonly signals).
    • isWriteSignal(val): Returns true if the value is a signal that supports the .set() write API.
    import { signal, computed, readonly, isReadSignal, isWriteSignal } from '@maverick-js/signals';
    
    const $a = signal(10);
    const $b = computed(() => 10);
    const $c = readonly($a);
    
    isReadSignal($a); // true
    isReadSignal($b); // true
    isReadSignal($c); // true
    
    isWriteSignal($a); // true
    isWriteSignal($b); // false
    isWriteSignal($c); // false
  8. Manage scope and context with `getScope`, `scoped`, `getContext`, and `setContext`

    main

    Low-level APIs for managing execution context and data:

    • getScope(): Returns the currently executing parent scope.
    • scoped(fn, scope): Runs a function within a specific existing scope.
    • setContext(key, value): Sets a value on the parent scope for the given key.
    • getContext(key): Walks up the computation tree from the parent scope to find a value matching the key.
    import { root, getScope, scoped, getContext, setContext } from '@maverick-js/signals';
    
    const key = Symbol();
    
    root(() => {
      setContext(key, 100);
      
      root(() => {
        const value = getContext(key); // 100
      });
    });
  9. Create and update signals with `signal`

    main

    The signal function wraps a value into a reactive signal. You can read the current value by invoking the signal as a function fn(), and update it using the .set() method. Updates can be direct or use a functional updater (prev) => next.

    import { signal } from '@maverick-js/signals';
    
    const $a = signal(10);
    
    $a(); // read
    $a.set(20); // write
    $a.set((prev) => prev + 10); // write using updater
  10. Map lists with `computedMap` and `computedKeyedMap`

    main

    These helpers optimize reactive list transformations:

    • computedMap(source, mapper): Caches items by index. The index is fixed, but values can change. Best for primitives.
    • computedKeyedMap(source, mapper): Caches items by reference. The value is fixed, but the index can change (items move). Best for objects/referential checks.
    import { signal, tick } from '@maverick-js/signals';
    import { computedMap } from '@maverick-js/signals/map';
    import { computedKeyedMap } from '@maverick-js/signals/map';
    
    // computedMap example
    const source = signal([1, 2, 3]);
    const map = computedMap(source, (value, index) => ({
      i: index,
      get id() { return value() * 2; }
    }));
    
    // computedKeyedMap example
    const nodesSource = signal([{ id: 0 }, { id: 1 }]);
    const nodes = computedKeyedMap(nodesSource, (value, index) => {
      const div = document.createElement('div');
      div.setAttribute('id', String(value.id));
      return div;
    });
  11. Disable tracking with `peek` and `untrack`

    main

    Use these functions to read signal values without triggering dependency tracking or scope tracking:

    • peek(signal): Returns the current value of a signal without triggering observer tracking (the signal won't be registered as a dependency).
    • untrack(fn): Executes a function while disabling both observer tracking and scope tracking.
    import { signal, computed, peek, effect, untrack } from '@maverick-js/signals';
    
    const $a = signal(10);
    
    // peek: $a will not trigger updates on $b
    const $b = computed(() => {
      const value = peek($a);
    });
    
    // untrack: $a is an orphan and not tracked by the outer effect
    effect(() => {
      untrack(() => {
        const $a = signal(10);
      });
    });