Skip to content

alloc/preact-sigma

Repository files navigation

preact-sigma

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.

Install

pnpm add preact-sigma
npm install preact-sigma

Big Picture

Define 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: counter exposes the base state as a reactive immutable property.
  • doubled is a memoized reactive value exposed through computed().
  • increment() is action-wrapped automatically, so the state update is batched and untracked.
  • counter.on(...) returns stopThreshold, which unsubscribes the event listener.

Define Reusable State

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,
);

Expose Base State

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;

Memoize A Reactive Derivation

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;

Create A Tracked Query Method

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();

Read Base State Without Tracking

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,
);

Use Top-Level Lenses

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;

Spread A Handle To Expose Top-Level Properties

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;

Compose Managed States

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();

Own Resources And Dispose The Instance

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();

Update State

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: "" },
);

Emit Events

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 });
    },
  }),
  {},
);

Listen For Events

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();

Read Signals From A Managed State

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;

Peek At Public State

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();

Subscribe To Public State

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 It Inside A Component

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

Subscribe In useEffect

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);
});

Listen To DOM Or Managed-State Events In useEffect

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);
});

Reach For Signals Helpers

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);
});

Small Feature Model

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

License

MIT. See LICENSE-MIT.

About

An exploration in agent-friendly state management using Preact Signals and Immer

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors