trisgeist
v0.1.2
Published
small simple global state management library. class-based stores, selector subscriptions, derived state, redux devtools. inspired by flutter's `ChangeNotifier` and riverpod.
Maintainers
Readme
trisgeist
small simple global state management library. class-based stores, selector subscriptions, derived state, redux devtools. inspired by flutter's ChangeNotifier and riverpod.
install
bun add trisgeist
# or
npm install trisgeistbasic usage
extend Notifier, override build() to return your initial state, expose actions as methods.
import { Notifier } from "trisgeist";
type CounterState = { count: number };
class CounterNotifier extends Notifier<CounterState> {
override build() {
return { count: 0 };
}
increment() {
this.setState({ count: this.state.count + 1 });
}
}
export const counterNotifier = new CounterNotifier();then in your component:
import { useWatch } from "trisgeist/react";
import { counterNotifier } from "./counterNotifier";
export function Counter() {
const { count } = useWatch(counterNotifier, (o) => o.state);
return (
<button onClick={() => counterNotifier.increment()}>
clicked {count} times
</button>
);
}the selector (o) => o.state.count means the component only re-renders when count changes, not on any unrelated state update.
note:
useWatchlives attrisgeist/reactrather than the root export. this keeps the door open for other framework bindings (e.g.trisgeist/vue) without name collisions.
the rules
- always override
build()to return your initial state. don't also assignstate = {...}as a class field — class fields run after the constructor body, so it'll silently overwrite whateverbuild()returned. stateis settable, but every direct assignment must be followed bynotifyListeners()(or usesetState/scheduleNotify, which call it for you). if you setstatedirectly and don't notify, subscribers won't update.
derived stores
use watch() inside build() to make a store that recomputes when other stores change — same idea as riverpod's ref.watch.
class FilteredEventsNotifier extends Notifier<Event[]> {
override build() {
const events = this.watch(eventsNotifier, (o) => o.state);
const filters = this.watch(filtersNotifier, (o) => o.state);
return events.filter((e) =>
filters.category === "all" || e.category === filters.category
);
}
}
export const filteredEventsNotifier = new FilteredEventsNotifier();when filtersNotifier or eventsNotifier updates, build() reruns automatically. no wiring needed.
note: dependencies declared with
watch()must be static — callwatch()on the same stores, in the same order, every timebuild()runs. only the first call (during construction) actually registers subscriptions, so conditionalwatch()calls won't be tracked correctly afterward.
debounced updates
if you're getting a burst of updates (websocket events, stream data) and don't want a re-render for each one, assign this.state directly (no merge) and call scheduleNotify() instead of setState:
class MessagesNotifier extends Notifier<Message[]> {
override debounceDuration = 50; // default is 100ms
override build() {
return [] as Message[];
}
onMessage(msg: Message) {
this.state = [...this.state, msg];
this.scheduleNotify(); // batches into one re-render once the burst settles
}
}scheduleNotify() resets a timer on each call and only fires notifyListeners() once things settle. unlike setState, it does not merge — set this.state to the full new value yourself first.
reading from other stores
stores are just singletons, import and read from them anywhere:
class CartNotifier extends Notifier<CartState> {
checkout() {
const { currency } = settingsNotifier.state;
this.setState({ total: calculateTotal(this.state.items, currency) });
}
}which update method to use
| method | when |
|---|---|
| setState(partial) | shallow merge + notify immediately — use this for almost everything |
| this.state = ... + notifyListeners() | full state replacement, then notify |
| this.state = ... + scheduleNotify() | full replacement, debounced notify, for burst updates |
devtools
connects to redux devtools automatically if you have the extension installed. each store shows up as its own instance in the dropdown. time travel works on source stores — derived ones recompute naturally.
no config needed, just install the extension.
react native
works fine, useSyncExternalStore is react core not web-specific.
api
Notifier<TState> — trisgeist
| member | description |
|---|---|
| state | current state, settable — direct assignment requires a notifyListeners()/scheduleNotify() call after |
| build() | must override. return initial state here, call watch() to declare deps |
| setState(partial) | shallow merge + notify |
| notifyListeners() | manually flush listeners |
| scheduleNotify() | debounced flush |
| debounceDuration | debounce ms, default 100, override per store |
| watch(store, selector) | declare a static dep inside build(), reruns build when it changes |
| subscribe(listener, selector) | raw subscription, returns unsub fn |
useWatch(store, selector?) — trisgeist/react
import { useWatch } from "trisgeist/react";
const state = useWatch(myNotifier); // whole state, re-renders on any change
const count = useWatch(myNotifier, (o) => o.state.count); // only re-renders when count changes