| name | stan |
| description | Guidance for working with @rkrupinski/stan, a minimal, type-safe, framework-agnostic state management library for JavaScript. Ships with first-class React and Vue bindings today, with more framework integrations planned. Use when the user is using, learning, or integrating Stan atoms, selectors, the vanilla store, or any framework bindings. |
| license | MIT |
| metadata | {"homepage":"https://stan.party","repository":"https://github.com/rkrupinski/stan"} |
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
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');
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));
reset(myAtom(store));
React bindings
import {
StanProvider,
useStan,
useStanValue,
useSetStanValue,
useStanValueAsync,
useStanRefresh,
useStanReset,
useStanCallback,
} from '@rkrupinski/stan/react';
Reading / writing
const [count, setCount] = useStan(countAtom);
const double = useStanValue(doubleAtom);
const setCount = useSetStanValue(countAtom);
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();
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/react or @rkrupinski/stan/vue), never from @rkrupinski/stan.
- Vue has no
useSetStanValue. Use useStan (and ignore the read side) or useStanCallback's set when 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. Pass computed(() => family(param)) (or any Ref<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
signal through to fetch (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, and Dates will either break or stringify unstably.
atomFamily retains every member for the life of the store. It has no eviction policy and no cachePolicy option. Each unique param creates a permanent atom (and runs its effects). Don't feed unbounded user input into an atom family.
selectorFamily's cachePolicy controls 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 optional ttl adds 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
areValuesEqual when 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 through useStanValueAsync as { type: 'error', reason }. Wrap your own try/catch inside the selector if you want a fallback value instead.
- Calling
atom(store) or selector(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 shares DEFAULT_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' });
selectorFamily<Result[], string>(query => () => search(query), {
tag: query => `search:${query}`,
});
Further reading