Signal variants

Writable signals with an extra edge. mutable edits an object or array in place, derived gives you a two-way slice of another signal, and toWritable makes any read-only signal writable.

@mmstack/primitivesnpm

#mutable

mutable is a WritableSignal with two extra methods, mutate and inline, for editing an object or array in place. It is cheaper than spreading a large value on every change (update(prev => ({ ...prev }))) and it still notifies readers.

import { mutable } from '@mmstack/primitives';

const user = mutable({ name: 'John', age: 30 });

user.mutate((prev) => {
  prev.age++;
  return prev;
});

user.inline((prev) => {
  prev.age++;
}); // void return, same effect useful for array.push / map.set shorthand

mutate takes a function that returns the value; inline is the same but returns void, so you just edit and let it fall through. Both notify dependents after the callback runs.

One caveat. A computed() that returns a non-primitive value derived from a mutable signal must declare { equal: false } (or () => false). The reference-equality default would otherwise suppress the change notification, because the object edited in place keeps the same reference, so stability is at the leaves of the graph. Same principle applies to inputs, binding a non-primitive mutable to an input wont trigger it, pass the whole signal through.

import { computed } from '@angular/core';
import { mutable } from '@mmstack/primitives';

const list = mutable([1, 2, 3]);

// returns a non-primitive derived from a mutable, so opt out of ref-equality
const doubled = computed(() => list().map((n) => n * 2), { equal: () => false });

mutable is an extension of signal so .set() / .update & passed-in equality work in the same immutable manner as they would otherwise, it just adds the option + handling for mutation.

#derived

derived gives you a two-way-bound slice of another WritableSignal. Writes to the slice update the source, and changes to the source flow through. Use a key or index shorthand for object and array slices.

Sidenote, this is fundementally just a reactive variation of a write-through lens :)

import { signal } from '@angular/core';
import { derived } from '@mmstack/primitives';

const user = signal({ name: 'John', age: 30 });
const name = derived(user, 'name'); // WritableSignal<string>

const list = signal([1, 2, 3]);
const second = derived(list, 1); // WritableSignal<number>, the item at index 1

name.set('Ada'); // user() is now { name: 'Ada', age: 30 }

For anything beyond a plain slice, pass a { from, onChange } pair. from reads the value out of the source, and onChange writes the next value back into it.

import { signal } from '@angular/core';
import { derived } from '@mmstack/primitives';

const user = signal({ name: 'John' });

const upper = derived(user, {
  from: (u) => u.name.toUpperCase(),
  onChange: (next) => user.update((u) => ({ ...u, name: next.toLowerCase() })),
});

Mutability propagates. When the source is a MutableSignal, the derived slice is one too, so derived(state, 'items').mutate(...) writes back correctly.

Writing through a null or undefined source drops the write by default. Pass vivify on the key or index form to create the missing container instead: 'object', 'array', 'auto' (an array for index keys, an object otherwise), or a () => container factory.

import { signal } from '@angular/core';
import { derived } from '@mmstack/primitives';

const user = signal<{ name: string } | null>(null);
derived(user, 'name', { vivify: 'object' }).set('Ada');
// user() is now { name: 'Ada' }

#toWritable

toWritable turns any read-only Signal<T> into a WritableSignal<T> by supplying your own set (and optional update) implementation. It powers derived under the hood. Reach for it directly when you have a computed you want to expose as writable.

import { computed, signal } from '@angular/core';
import { toWritable } from '@mmstack/primitives';

const user = signal({ name: 'John' });

const name = toWritable(
  computed(() => user().name),
  (next) => user.update((u) => ({ ...u, name: next })),
);