Managed UI state for Preact Signals, with Immer-powered updates and a small public API.
For naming and API design conventions, see best-practices.md.
pnpm add preact-sigmanpm install preact-sigmaDefine state once, expose a few methods, and return reactive immutable data from the public instance.
import { computed, defineManagedState, type StateHandle } from "preact-sigma";
type CounterEvents = {
thresholdReached: [{ count: number }];
};
type CounterState = number;
const Counter = defineManagedState(
(counter: StateHandle<CounterState, CounterEvents>, step: number) => {
const doubled = computed(() => counter.get() * 2);
return {
count: counter,
doubled,
increment() {
counter.set((value) => value + step);
if (counter.get() >= 10) {
counter.emit("thresholdReached", { count: counter.get() });
}
},
reset() {
counter.set(0);
},
};
},
0,
);
const counter = new Counter(2);
const stopThreshold = counter.on("thresholdReached", (event) => {
console.log(event.count);
});
counter.count;
counter.doubled;
counter.increment();
stopThreshold();count: counterexposes the base state as a reactive immutable property.doubledis a memoized reactive value exposed throughcomputed().increment()is action-wrapped automatically, so the state update is batched and untracked.counter.on(...)returnsstopThreshold, which unsubscribes the event listener.
Use defineManagedState() when you want a reusable managed-state class.
import { defineManagedState, type StateHandle } from "preact-sigma";
type CounterState = number;
const Counter = defineManagedState(
(counter: StateHandle<CounterState>) => ({
count: counter,
increment() {
counter.set((value) => value + 1);
},
}),
0,
);Return the constructor handle when you want the base state to appear as a reactive immutable property.
import { defineManagedState, type StateHandle } from "preact-sigma";
type CounterState = number;
const Counter = defineManagedState(
(count: StateHandle<CounterState>) => ({
count,
}),
0,
);
new Counter().count;Use computed() when you want a memoized reactive value on the public instance.
import { computed, defineManagedState, type StateHandle } from "preact-sigma";
type CounterState = number;
const Counter = defineManagedState(
(counter: StateHandle<CounterState>) => ({
doubled: computed(() => counter.get() * 2),
}),
0,
);
new Counter().doubled;Use query() when you want a public method whose reads stay tracked.
Query functions read from closed-over handles or signals and do not use
instance this.
import { defineManagedState, query, type StateHandle } from "preact-sigma";
type CounterState = number;
const Counter = defineManagedState(
(counter: StateHandle<CounterState>) => ({
isPositive: query(() => counter.get() > 0),
}),
0,
);
new Counter().isPositive();Use handle.peek() when you need the current base-state snapshot without creating a reactive dependency.
import { defineManagedState, type StateHandle } from "preact-sigma";
type CounterState = number;
const Counter = defineManagedState(
(counter: StateHandle<CounterState>) => ({
logNow() {
console.log(counter.peek());
},
}),
0,
);When the base state is object-shaped, the constructor handle exposes a shallow lens for each top-level property, and you can return that lens directly.
import { computed, defineManagedState, type StateHandle } from "preact-sigma";
type SearchState = {
query: string;
};
const Search = defineManagedState(
(search: StateHandle<SearchState>) => ({
query: search.query,
trimmedQuery: computed(() => search.query.get().trim()),
setQuery(query: string) {
search.query.set(query);
},
}),
{ query: "" },
);
new Search().query;When the base state is object-shaped, spread the handle into the returned object to expose its current top-level lenses at once.
import { defineManagedState, type StateHandle } from "preact-sigma";
type SearchState = {
page: number;
query: string;
};
const Search = defineManagedState(
(search: StateHandle<SearchState>) => ({
...search,
nextPage() {
search.page.set((page) => page + 1);
},
}),
{ page: 1, query: "" },
);
const search = new Search();
search.query;
search.page;Return another managed-state instance when you want to expose it unchanged as a property. Composed managed states are available through direct property access and whole-state snapshots.
import { defineManagedState, type StateHandle } from "preact-sigma";
type CounterState = number;
const Counter = defineManagedState(
(count: StateHandle<CounterState>) => ({
count,
increment() {
count.set((value) => value + 1);
},
}),
0,
);
type DashboardState = {
ready: boolean;
};
const Dashboard = defineManagedState(
(dashboard: StateHandle<DashboardState>) => ({
dashboard,
counter: new Counter(),
}),
{ ready: false },
);
new Dashboard().counter.increment();Use handle.own() to register cleanup functions or disposables, and call .dispose() when the managed state should release them.
import { defineManagedState, type StateHandle } from "preact-sigma";
type PollerState = {
ticks: number;
};
const Poller = defineManagedState(
(poller: StateHandle<PollerState>) => ({
ticks: poller.ticks,
start() {
const interval = window.setInterval(() => {
poller.ticks.set((ticks) => ticks + 1);
}, 1000);
poller.own([() => window.clearInterval(interval)]);
},
}),
{ ticks: 0 },
);
const poller = new Poller();
poller.start();
poller.dispose();Pass an Immer producer to .set() when your base state is object-shaped.
import { defineManagedState, type StateHandle } from "preact-sigma";
type SearchState = {
query: string;
};
const Search = defineManagedState(
(search: StateHandle<SearchState>) => ({
setQuery(query: string) {
search.set((draft) => {
draft.query = query;
});
},
}),
{ query: "" },
);Use .emit() to publish a custom event with zero or one argument.
import { defineManagedState, type StateHandle } from "preact-sigma";
type TodoEvents = {
saved: [];
selected: [{ id: string }];
};
type TodoState = {};
const Todo = defineManagedState(
(todo: StateHandle<TodoState, TodoEvents>) => ({
save() {
todo.emit("saved");
},
select(id: string) {
todo.emit("selected", { id });
},
}),
{},
);Use .on() to subscribe to custom events from a managed state instance.
const todo = new Todo();
const stopSaved = todo.on("saved", () => {
console.log("saved");
});
const stopSelected = todo.on("selected", (event) => {
console.log(event.id);
});
stopSaved();
stopSelected();Use .get(key) for one exposed signal-backed property or .get() for the whole public state signal.
Keyed reads do not target composed managed-state properties.
const counter = new Counter();
const countSignal = counter.get("count");
const counterSignal = counter.get();
countSignal.value;
counterSignal.value.count;Use .peek(key) for one exposed signal-backed property or .peek() for the whole public snapshot.
Keyed peeks do not target composed managed-state properties.
const counter = new Counter();
counter.peek("count");
counter.peek();Use .subscribe(key, listener) for one exposed signal-backed property or .subscribe(listener) for the whole public state.
Listeners receive the current value immediately and then future updates.
Keyed subscriptions do not target composed managed-state properties.
const counter = new Counter();
const stopCount = counter.subscribe("count", (count) => {
console.log(count);
});
const stopState = counter.subscribe((value) => {
console.log(value.count);
});
stopCount();
stopState();Use useManagedState() when you want the same pattern directly inside a component.
import { useManagedState, type StateHandle } from "preact-sigma";
type SearchState = {
query: string;
};
function SearchBox() {
const search = useManagedState(
(search: StateHandle<SearchState>) => ({
query: search.query,
setQuery(query: string) {
search.query.set(query);
},
}),
() => ({ query: "" }),
);
return (
<input value={search.query} onInput={(event) => search.setQuery(event.currentTarget.value)} />
);
}Use useSubscribe() with any subscribable source, including managed state and Preact signals.
The listener receives the current value immediately and then future updates.
import { useSubscribe } from "preact-sigma";
useSubscribe(counter, (value) => {
console.log(value.count);
});Use useEventTarget() for either DOM events or managed-state events.
import { useEventTarget } from "preact-sigma";
useEventTarget(window, "resize", () => {
console.log(window.innerWidth);
});useEventTarget(counter, "thresholdReached", (event) => {
console.log(event.count);
});batch and untracked are re-exported from @preact/signals.
import { batch, untracked } from "preact-sigma";
batch(() => {
counter.increment();
counter.reset();
});
untracked(() => {
console.log(counter.count);
});This pattern works well when a component or UI feature needs a small state model with a few public methods and derived values.
import { defineManagedState, type StateHandle } from "preact-sigma";
type DialogState = boolean;
const Dialog = defineManagedState(
(dialog: StateHandle<DialogState>) => ({
open: dialog,
show() {
dialog.set(true);
},
hide() {
dialog.set(false);
},
}),
false,
);Keep using plain useState() when the state is trivial.
const [open, setOpen] = useState(false);MIT. See LICENSE-MIT.