The manager to rule your state.
This page mirrors the demo content and adds a full API reference.
- Overview
- Philosophy
- Demo
- Quickstart
- Handlers
- Hooks
- Singletons
- Composition
- API Guide
- Devtools
- Cleanup
- API Reference
- Migration
StatusQuo is a small, framework-agnostic state layer that focuses on explicit lifecycle, clear action APIs, and a minimal subscription surface. It ships two handler implementations with the same public interface: RxJS-backed observables and signals-backed stores.
- Swap the engine, keep the API. Your UI code stays the same when you switch from RxJS to Signals.
- Separate view and state. Handlers own transitions and expose actions; views subscribe to snapshots.
- Framework-agnostic core. Business logic lives outside the UI library; hooks provide the glue.
Live docs and demo:
https://veams.github.io/status-quo/
Install:
npm install @veams/status-quo rxjs @preact/signals-coreCreate a store and use it in a component:
import { ObservableStateHandler, useStateFactory } from '@veams/status-quo';
type CounterState = { count: number };
type CounterActions = {
increase: () => void;
decrease: () => void;
};
class CounterStore extends ObservableStateHandler<CounterState, CounterActions> {
constructor() {
super({ initialState: { count: 0 } });
}
getActions(): CounterActions {
return {
increase: () => this.setState({ count: this.getState().count + 1 }),
decrease: () => this.setState({ count: this.getState().count - 1 }),
};
}
}
const [state, actions] = useStateFactory(() => new CounterStore(), []);Optional global setup (e.g. with a custom deep-equality comparator):
import equal from 'fast-deep-equal';
import { setupStatusQuo } from '@veams/status-quo';
setupStatusQuo({
distinct: {
comparator: equal,
},
});StatusQuo provides two handler implementations with the same public interface:
ObservableStateHandler(RxJS-backed)SignalStateHandler(Signals-backed)
Both are built on BaseStateHandler, which provides the shared lifecycle and devtools support.
Use useStateHandler + useStateActions + useStateSubscription as the base composition.
useStateFactory and useStateSingleton are shortcut APIs over that composition.
For full signatures and practical examples, see API Guide.
useStateHandler(factory, params)- Creates and memoizes one handler instance per component.
useStateActions(handler)- Returns actions without subscribing to state.
useStateSubscription(handlerOrSingleton, selector?, isEqual?)- Subscribes to full state or a selected slice and returns
[state, actions].
- Subscribes to full state or a selected slice and returns
useStateFactory(factory, selector?, isEqual?, params?)- Shortcut for
useStateHandler + useStateSubscription.
- Shortcut for
useStateSingleton(singleton, selector?, isEqual?)- Shortcut for
useStateSubscription(singleton, selector?, isEqual?).
- Shortcut for
Recommended composition:
const handler = useStateHandler(createUserStore, []);
const actions = useStateActions(handler);
const [name] = useStateSubscription(handler, (state) => state.user.name);
const [singletonName] = useStateSubscription(UserSingleton, (state) => state.user.name);Use singletons for shared state across multiple components.
import { makeStateSingleton, useStateSingleton } from '@veams/status-quo';
// Default behavior: singleton is destroyed when the last consumer unmounts.
const CounterSingleton = makeStateSingleton(() => new CounterStore());
const [state, actions] = useStateSingleton(CounterSingleton);Keep a singleton instance alive across unmounts:
const PersistentCounterSingleton = makeStateSingleton(() => new CounterStore(), {
destroyOnNoConsumers: false,
});Use this for app-level stores that should survive route/component unmounts. Keep the default for stores that should release resources when unused.
Use only the slice you need. RxJS makes multi-source composition powerful and declarative with operators like combineLatest, switchMap, or debounceTime. Signals can derive values with computed and wire them into a parent store via bindSubscribable.
import { combineLatest } from 'rxjs';
// RxJS: combine handler streams (RxJS shines here)
class AppSignalStore extends SignalStateHandler<AppState, AppActions> {
private counter$ = CounterObservableStore.getInstance().getStateAsObservable();
private card$ = new CardObservableHandler();
constructor() {
super({ initialState: { counter: 0, cardTitle: '' }});
this.subscriptions.push(
combineLatest([
this.counter$,
this.card$,
]).subscribe(([counterState, cardState]) => {
this.setState({
counter: counterState,
cardTitle: cardState.title,
}, 'sync-combined');
})
)
}
}
// Signals: combine derived values via computed + bindSubscribable
import { computed } from '@preact/signals-core';
class AppSignalStore extends SignalStateHandler<AppState, AppActions> {
private counter = CounterSignalHandler.getInstance();
private card = new CardSignalHandler();
private combined$ = computed(() => ({
counter: this.counter.getSignal().value,
cardTitle: this.card.getSignal().value.title,
}));
constructor() {
super({ initialState: { counter: 0, cardTitle: '' }});
this.bindSubscribable(
{ subscribe: this.combined.subscribe.bind(this.combined), getSnapshot: () => this.combined.value },
(nextState) => this.setState(nextState, 'sync-combined')
);
}
}This section documents the primary public API with behavior notes and usage examples.
Sets global runtime defaults for distinct update behavior. Per-handler options still override the global setup.
type StatusQuoConfig = {
distinct?: {
enabled?: boolean; // default: true
comparator?: (previous: unknown, next: unknown) => boolean; // default: JSON compare
};
};import equal from 'fast-deep-equal';
import { setupStatusQuo } from '@veams/status-quo';
setupStatusQuo({
distinct: {
comparator: equal,
},
});Creates one handler instance per component mount and returns it.
factory: function returning aStateSubscriptionHandlerparams: optional factory params tuple- lifecycle note: params are applied when the handler instance is created for that mount
const handler = useStateHandler(createUserStore, []);Returns actions from a handler without subscribing to state changes. Use this in action-only components to avoid rerenders from state updates.
const handler = useStateHandler(createUserStore, []);
const actions = useStateActions(handler);Subscribes to either a handler instance or a singleton and returns [selectedState, actions].
source:StateSubscriptionHandlerorStateSingletonselector: optional projection function; defaults to identityisEqual: optional equality function; defaults toObject.is
Full snapshot subscription:
const handler = useStateHandler(createUserStore, []);
const [state, actions] = useStateSubscription(handler);Selector subscription:
const [name, actions] = useStateSubscription(
handler,
(state) => state.user.name
);Selector with custom equality:
const [profile] = useStateSubscription(
handler,
(state) => state.user.profile,
(current, next) => current.id === next.id && current.role === next.role
);Singleton source:
const [session, actions] = useStateSubscription(SessionSingleton);Lifecycle note for singleton sources:
- Consumers are ref-counted.
- The singleton instance is only destroyed when the last consumer unmounts and
destroyOnNoConsumers !== false.
Shortcut API for useStateHandler + useStateSubscription.
useStateFactory(factory, params)useStateFactory(factory, selector, params)useStateFactory(factory, selector, isEqual, params)
const [state, actions] = useStateFactory(createUserStore, []);
const [name] = useStateFactory(createUserStore, (state) => state.user.name, []);
const [profile] = useStateFactory(
createUserStore,
(state) => state.user.profile,
(current, next) => current.id === next.id,
[]
);Creates a shared singleton provider for a handler instance.
const UserSingleton = makeStateSingleton(() => new UserStore());Options:
type StateSingletonOptions = {
destroyOnNoConsumers?: boolean; // default: true
};true(default): destroy instance after last consumer unmountsfalse: keep instance alive across periods with zero consumers
const PersistentUserSingleton = makeStateSingleton(() => new UserStore(), {
destroyOnNoConsumers: false,
});Shortcut API for useStateSubscription(singleton, selector?, isEqual?).
const [state, actions] = useStateSingleton(UserSingleton);
const [name] = useStateSingleton(UserSingleton, (state) => state.user.name);Enable Redux Devtools integration with options.devTools:
class CounterStore extends ObservableStateHandler<CounterState, CounterActions> {
constructor() {
super({
initialState: { count: 0 },
options: { devTools: { enabled: true, namespace: 'Counter' } },
});
}
}Handlers expose subscribe, getSnapshot, and destroy for custom integrations:
const unsubscribe = store.subscribe(() => {
console.log(store.getSnapshot());
});
unsubscribe();
store.destroy();Required interface implemented by all handlers.
interface StateSubscriptionHandler<V, A> {
subscribe(listener: () => void): () => void;
subscribe(listener: (value: V) => void): () => void;
getSnapshot: () => V;
destroy: () => void;
getInitialState: () => V;
getActions: () => A;
}Shared base class for all handlers.
Constructor:
protected constructor(initialState: S)Public methods:
getInitialState(): SgetState(): SgetSnapshot(): SsetState(next: Partial<S>, actionName = 'change'): voidsubscribe(listener: () => void): () => void(abstract)subscribe(listener: (value: S) => void): () => void(abstract)destroy(): voidgetActions(): A(abstract)
Protected helpers:
getStateValue(): S(abstract)setStateValue(next: S): void(abstract)initDevTools(options?: { enabled?: boolean; namespace: string }): voidbindSubscribable<T>(service: { subscribe: (listener: (value: T) => void) => () => void; getSnapshot?: () => T }, onChange: (value: T) => void, selector?: (value: T) => T, isEqual?: (current: T, next: T) => boolean): voidbindSubscribable<T, Sel>(service: { subscribe: (listener: (value: T) => void) => () => void; getSnapshot?: () => T }, onChange: (value: Sel) => void, selector: (value: T) => Sel, isEqual?: (current: Sel, next: Sel) => boolean): void- Registers the subscription on
this.subscriptionsand invokesonChangewith the current snapshot when available. - If
selectoris omitted, identity selection is used. onChangeis only called when selected value changes according toisEqual(defaultObject.is).
- Registers the subscription on
RxJS-backed handler. Extends BaseStateHandler.
Constructor:
protected constructor({
initialState,
options
}: {
initialState: S;
options?: {
devTools?: { enabled?: boolean; namespace: string };
distinct?: {
enabled?: boolean;
comparator?: (previous: S, next: S) => boolean;
};
useDistinctUntilChanged?: boolean; // optional override
};
})Public methods:
getStateAsObservable(options?: { useDistinctUntilChanged?: boolean }): Observable<S>getStateItemAsObservable(key: keyof S): Observable<S[keyof S]>getObservable(key: keyof S): Observable<S[keyof S]>subscribe(listener: () => void): () => voidsubscribe(listener: (value: S) => void): () => void
Notes:
- The observable stream uses
distinctUntilChangedby default. - Distinct behavior can be configured globally via
setupStatusQuoor per handler viaoptions.distinct. subscribefires immediately with the current snapshot and then on subsequent changes.- Subscribers receive the next state snapshot as a callback argument.
Signals-backed handler. Extends BaseStateHandler.
Constructor:
protected constructor({
initialState,
options
}: {
initialState: S;
options?: {
devTools?: { enabled?: boolean; namespace: string };
distinct?: {
enabled?: boolean;
comparator?: (previous: S, next: S) => boolean;
};
useDistinctUntilChanged?: boolean;
};
})Public methods:
getSignal(): Signal<S>subscribe(listener: () => void): () => voidsubscribe(listener: (value: S) => void): () => void
Notes:
- Distinct behavior defaults to enabled.
- Configure it globally via
setupStatusQuoor per handler viaoptions.distinct. useDistinctUntilChangedremains available as a shorthand enable/disable override.
type StatusQuoConfig = {
distinct?: {
enabled?: boolean;
comparator?: (previous: unknown, next: unknown) => boolean;
};
};
function setupStatusQuo(config?: StatusQuoConfig): voidtype StateSingletonOptions = {
destroyOnNoConsumers?: boolean; // default: true
};
function makeStateSingleton<S, A>(
factory: () => StateSubscriptionHandler<S, A>,
options?: StateSingletonOptions
): {
getInstance: () => StateSubscriptionHandler<S, A>;
}Lifecycle behavior:
destroyOnNoConsumers: true(default): destroy and recreate singleton instances with mount lifecycle.destroyOnNoConsumers: false: keep the same singleton instance alive when no component is subscribed.
useStateHandler<V, A, P extends unknown[]>(factory: (...args: P) => StateSubscriptionHandler<V, A>, params?: P)- Returns
StateSubscriptionHandler<V, A>.
- Returns
useStateActions<V, A>(handler: StateSubscriptionHandler<V, A>)- Returns
A.
- Returns
useStateSubscription<V, A, Sel = V>(source: StateSubscriptionHandler<V, A> | StateSingleton<V, A>, selector?: (state: V) => Sel, isEqual?: (current: Sel, next: Sel) => boolean)- Returns
[state, actions].
- Returns
useStateFactory<V, A, P extends unknown[], Sel = V>(factory: (...args: P) => StateSubscriptionHandler<V, A>, selector?: (state: V) => Sel, isEqual?: (current: Sel, next: Sel) => boolean, params?: P)- Returns
[state, actions].
- Returns
useStateSingleton<V, A, Sel = V>(singleton: StateSingleton<V, A>, selector?: (state: V) => Sel, isEqual?: (current: Sel, next: Sel) => boolean)- Returns
[state, actions].
- Returns
From pre-1.0 releases:
- Rename
StateHandler->ObservableStateHandler. - Implement
subscribe()andgetSnapshot()on custom handlers. - Replace
getObservable()usage withsubscribe()in custom integrations. - Update devtools config:
- From:
super({ initialState, devTools: { ... } }) - To:
super({ initialState, options: { devTools: { ... } } })
- From:
