Imported from rkrupinski/stan (
skills/stan/SKILL.md). Install upstream withnpx skills add rkrupinski/stan --skill stan. Copyright stays with the author (MIT).
Working with @rkrupinski/stan
Stan is a minimal, type-safe, framework-agnostic state management library. The core has no framework dependency; React and Vue bindings ship alongside today, with more integrations planned.
Mental model
- Three entry points - use the right one:
@rkrupinski/stan- core primitives (atoms, selectors, store, utils). Framework-agnostic.@rkrupinski/stan/react- React hooks andStanProvider. Peer dep: React ≥19.@rkrupinski/stan/vue- Vue composables,StanProvidercomponent, andprovideStan. Peer dep: Vue ≥3.4.- Never import framework bindings from the core entry point, and don't mix React and Vue bindings in the same app.
- Atoms and selectors are scoped factory functions, not values:
type Scoped<T> = (store: Store) => T;myAtom(store)returns the per-storeStatehandle (memoized - same call, same instance). - A
Storeholds values; primitives only define shape. When there's noStanProvider, framework bindings fall back toDEFAULT_STORE. atom→WritableState<T>(has.set).selector→ReadonlyState<T>(read-only, supportsrefresh).
Core API
atom
import { atom } from '@rkrupinski/stan';
const countAtom = atom(0, { tag: 'count' });
Options: tag, effects, areValuesEqual (default: strict equality).
atomFamily
import { atomFamily } from '@rkrupinski/stan';
const todoAtom = atomFamily<Todo, string>(
id => ({ id, done: false, text: '' }),
{ tag: id => `todo:${id}` },
);
todoAtom('abc'); // Scoped<WritableState<Todo>>
P must be JSON-serializable. Keys are normalized via fast-json-stable-stringify (object property order doesn't matter).
selector
import { selector } from '@rkrupinski/stan';
const doubleAtom = selector(({ get }) => get(countAtom) * 2);
const userQuery = selector(async ({ get, signal }) => {
const res = await fetch(`/api/users/${get(userIdAtom)}`, { signal });
return res.json();
});
The selector fn receives { get, signal }. get(dep) subscribes you to dep. signal is an AbortSignal that fires when deps change or the selector is torn down.
selectorFamily
import { selectorFamily } from '@rkrupinski/stan';
const searchResults = selectorFamily<SearchResult[], string>(
phrase =>
async ({ signal }) => {
const res = await fetch(`/api/search?q=${phrase}`, { signal });
return res.json();
},
{ cachePolicy: { type: 'lru', maxSize: 10 } },
);
cachePolicy: { type: 'keep-all' } (default) | { type: 'most-recent' } | { type: 'lru', maxSize }. Every variant additionally accepts an optional ttl: number (ms); the timer starts when an entry is added and expired entries are evicted lazily on next access. Eviction is deferred while an entry has subscribers (same defer-while-mounted rule as size-based eviction).
refresh / reset
import { refresh, reset } from '@rkrupinski/stan';
refresh(mySelector(store)); // force re-eval (lazy if unmounted)
reset(myAtom(store)); // back to initial value, notifies subscribers
React bindings
import {
StanProvider,
useStan,
useStanValue,
useSetStanValue,
useStanValueAsync,
useStanRefresh,
useStanReset,
useStanCallback,
} from '@rkrupinski/stan/react';
Reading / writing
const [count, setCount] = useStan(countAtom); // read + write
const double = useStanValue(doubleAtom); // read only (subscribes)
const setCount = useSetStanValue(countAtom); // write only (does NOT subscribe - render optimization)
Async selectors
const result = useStanValueAsync(userQuery);
if (result.type === 'loading') return <Spinner />;
if (result.type === 'error') return <Error reason={result.reason} />;
return <div>{result.value.name}</div>;
AsyncValue<T, E> is { type: 'loading' } | { type: 'ready'; value: T } | { type: 'error'; reason: E }. The underlying selector must return a PromiseLike<T>.
Imperative refresh / reset
const refreshUser = useStanRefresh(userQuery);
const resetCount = useStanReset(countAtom);
useStanCallback
Event-handler helper. Gives you get / set / reset / refresh without subscribing the component to the state you touch.
const handleRetry = useStanCallback(({ refresh }) => (id: string) => {
refresh(todoDetails(id));
});
The second argument is a React deps list (default []). The returned callback reference is stable across renders for the same [store, ...deps].
StanProvider
<StanProvider store={myStore}>
<App />
</StanProvider>
If no store prop is passed, the provider creates a fresh store. Use it for SSR (per-request isolation) and multi-store apps. Without any provider, hooks use DEFAULT_STORE.
Vue bindings
import {
StanProvider,
provideStan,
useStan,
useStanValue,
useStanValueAsync,
useStanRefresh,
useStanReset,
useStanCallback,
} from '@rkrupinski/stan/vue';
All examples use <script setup lang="ts"> SFCs. Peer dep: Vue ≥3.4.
Reading / writing
useStan returns a WritableComputedRef<T> - reading .value subscribes, assigning to .value writes. Works with v-model. useStanValue returns a Readonly<Ref<T>> for read-only state.
<script setup lang="ts">
import { atom, selector } from '@rkrupinski/stan';
import { useStan, useStanValue } from '@rkrupinski/stan/vue';
const countAtom = atom(0);
const doubleAtom = selector(({ get }) => get(countAtom) * 2);
const count = useStan(countAtom); // read + write
const double = useStanValue(doubleAtom); // read only
</script>
<template>
<input v-model.number="count" type="number" />
<pre>{{ double }}</pre>
</template>
For family calls with a reactive parameter, wrap the scoped state in computed:
<script setup lang="ts">
import { computed } from 'vue';
import { useStanValueAsync } from '@rkrupinski/stan/vue';
const props = defineProps<{ userId: string }>();
const data = useStanValueAsync(computed(() => userQuery(props.userId)));
</script>
Bare getters are not accepted - Scoped<T> is itself a function, so () => family(param) can't be disambiguated from a plain scoped state. Always use computed(...) for reactive inputs.
There is no useSetStanValue equivalent in the Vue API - use useStan and ignore the read side, or reach for useStanCallback's set helper when you explicitly don't want a subscription.
Async selectors
<script setup lang="ts">
import { useStanValueAsync } from '@rkrupinski/stan/vue';
const result = useStanValueAsync(userQuery);
</script>
<template>
<Spinner v-if="result.type === 'loading'" />
<Error v-else-if="result.type === 'error'" :reason="result.reason" />
<div v-else>{{ result.value.name }}</div>
</template>
AsyncValue<T, E> is identical to the React version: { type: 'loading' } | { type: 'ready'; value: T } | { type: 'error'; reason: E }.
Imperative refresh / reset
const refreshUser = useStanRefresh(userQuery);
const resetCount = useStanReset(countAtom);
useStanCallback
Same { get, set, reset, refresh } helpers as the React version. Takes no deps list - Vue's setup() runs once, so the returned callback already closes over the latest values.
<script setup lang="ts">
import { useStanCallback } from '@rkrupinski/stan/vue';
const reloadUser = useStanCallback(({ refresh }) => (id: string) => {
refresh(userQuery(id));
});
</script>
Unlike the other composables, useStanCallback reads the store at invocation time - so it picks up a new store even if the component's already set up.
StanProvider / provideStan
Two equivalent ways to supply a store. The component form:
<script setup lang="ts">
import { makeStore } from '@rkrupinski/stan';
import { StanProvider } from '@rkrupinski/stan/vue';
const myStore = makeStore();
</script>
<template>
<StanProvider :store="myStore">
<App />
</StanProvider>
</template>
Or call provideStan inside setup() when wrapping the template is awkward:
<script setup lang="ts">
import { makeStore } from '@rkrupinski/stan';
import { provideStan } from '@rkrupinski/stan/vue';
provideStan(makeStore());
</script>
Switching the :store prop on StanProvider re-subscribes composables automatically via Vue's reactivity. Use :key only when you also want to tear down local component state alongside the store swap.
<StanProvider :store="stores[active]">
<App />
</StanProvider>
Without any provider, composables use DEFAULT_STORE - same fallback as React.
Vanilla usage
The core API works standalone - everything the hooks do is available directly:
import { atom, makeStore } from '@rkrupinski/stan';
const store = makeStore();
const countAtom = atom(0);
const state = countAtom(store);
state.get(); // 0
state.set(1);
state.set(prev => prev + 1);
const unsubscribe = state.subscribe(v => console.log(v));
Patterns
Async with cancellation
Pass signal to fetch. Stan aborts the previous in-flight request whenever deps change.
const userQuery = selector(async ({ get, signal }) => {
const res = await fetch(`/api/user/${get(userIdAtom)}`, { signal });
return res.json();
});
Chained async selectors
await get(upstream) composes. Rejected upstream promises propagate through await.
const profile = selector(async ({ get }) => {
const user = await get(userQuery);
return get(profileFor(user.id));
});
Persistence via atom effects
const countAtom = atom(0, {
effects: [
({ init, onSet }) => {
const saved = localStorage.getItem('count');
if (saved) init(Number(saved));
onSet(v => localStorage.setItem('count', String(v)));
},
],
});
init only works during the first-read initialization phase. onSet fires on subsequent public set calls.
Dynamic dependencies
get() runs at evaluation time, so control flow decides what the selector actually subscribes to. Only the taken branch is tracked.
const visibleList = selector(({ get }) => {
if (get(showArchived)) return get(archivedList);
return get(activeList);
});
Bounded parameterized caches
const results = selectorFamily<Result[], string>(
query =>
async ({ signal }) => {
const res = await fetch(`/api/search?q=${query}`, { signal });
return res.json();
},
{ cachePolicy: { type: 'lru', maxSize: 5, ttl: 60_000 } },
);
maxSize caps how many parameterized instances are retained; ttl (optional, ms) additionally evicts entries that have outlived the window on next access. Both rules respect mounted entries.
Refresh one member of a family
const refreshTodo = useStanCallback(({ refresh }) => (id: string) => {
refresh(todoDetails(id));
});
Multi-store / store switching
const stores = [makeStore(), makeStore()];
<StanProvider store={stores[active]}>
<App />
</StanProvider>;
Each store is a fully independent state graph - atoms in one don't affect atoms in another.
React Suspense
import { use, Suspense } from 'react';
function UserName() {
const user = use(useStanValue(userQuery));
return <span>{user.name}</span>;
}
<Suspense fallback={<Spinner />}>
<UserName />
</Suspense>;
userQuery is a selector returning a promise. use() throws the promise into Suspense.
Gotchas
- Import framework bindings from their dedicated entry point (
@rkrupinski/stan/reactor@rkrupinski/stan/vue), never from@rkrupinski/stan. - Vue has no
useSetStanValue. UseuseStan(and ignore the read side) oruseStanCallback'ssetwhen you want to write without subscribing. - Vue composables with reactive family params require
computed(...), not a bare getter.Scoped<T>is itself a function, so() => family(param)is ambiguous with a plain scoped state - the API rejects the getter form. Passcomputed(() => family(param))(or anyRef<Scoped<...>>) and the composable re-subscribes automatically. - Selectors are lazy when unmounted. No subscribers → no re-evaluation until next
.get(). Don't expect a selector to update in the background if nothing is reading it. - Async selectors auto-abort on dep change. Always pass
signalthrough tofetch(or other cancellable APIs) - otherwise in-flight work leaks and results can race. - Family params must be JSON-serializable. Keys are normalized via
fast-json-stable-stringify(property order doesn't matter), but functions, symbols, class instances, andDates will either break or stringify unstably. atomFamilyretains every member for the life of the store. It has no eviction policy and nocachePolicyoption. Each unique param creates a permanent atom (and runs its effects). Don't feed unbounded user input into an atom family.selectorFamily'scachePolicycontrols which family members are retained, not whether their values are memoized. A selector always re-evaluates when its dependencies change -cachePolicy(keep-all|most-recent|lru) decides how many parameterized selector instances the family remembers, and the optionalttladds time-based eviction of those instances (an evicted instance is rebuilt and re-evaluated on next access). It is not a per-value "cache the result for N ms" knob.- Equality defaults to strict equality. Setting an object or array produces a new reference and will always notify subscribers, even if the contents are identical. Pass
areValuesEqualwhen structural equality matters. - Atom effects run lazily on first access, not at
atom(...)definition time.init()is only valid during that first-read phase; calling it later is a no-op. - Errors in selectors: synchronous errors throw out of
.get(); async errors surface throughuseStanValueAsyncas{ type: 'error', reason }. Wrap your own try/catch inside the selector if you want a fallback value instead. - Calling
atom(store)orselector(store)is cheap and idempotent - results are memoized per store. Safe to call in React render. - For SSR, always wrap the tree in
<StanProvider>so each request gets an isolated store. Without a provider, every request sharesDEFAULT_STORE.
Debugging
Stan ships with a Chrome DevTools extension for inspecting stores, tracking state history, and filtering entries.
Tag your state for readable labels:
atom('all', { tag: 'view' });
selector(filterTodos, { tag: 'filtered-todos' });
// Families: function tag maps param → label
selectorFamily<Result[], string>(query => () => search(query), {
tag: query => `search:${query}`,
});
Further reading
- Full distilled API reference: https://stan.party/llms-ctx-full.txt
- Documentation: https://stan.party
- Examples: https://github.com/rkrupinski/stan/tree/master/packages/examples
- Repository: https://github.com/rkrupinski/stan