Small, focused reactive state primitives for values, effects, and keyed stores. Frost State has zero runtime dependencies, works in Node and bundlers, and also ships a browser-friendly UMD bundle that exposes globalThis.State.
- Named exports for tree-shaking
- Prebuilt ESM and UMD bundles in
dist/ - No runtime dependencies
- JSDoc-powered IntelliSense
npm i @fr0st/stateFrost State's package entry point is ESM-only. Use import syntax in Node and bundlers.
import { useEffect, useState } from '@fr0st/state';Import the minified ESM bundle directly from a CDN:
<script type="module">
import { useEffect, useState } from 'https://cdn.jsdelivr.net/npm/@fr0st/state@latest/dist/frost-state.esm.min.js';
const count = useState(0);
useEffect(() => {
console.log('count =', count());
});
count(1);
</script>Load the bundle from your own copy or a CDN:
<script src="/path/to/dist/frost-state.min.js"></script>
<!-- or -->
<script src="https://cdn.jsdelivr.net/npm/@fr0st/state@latest/dist/frost-state.min.js"></script>
<script>
const { StateStore, useEffect, useState } = globalThis.State;
const count = useState(0);
useEffect(() => {
console.log('count =', count());
});
count(1);
</script>The package root resolves to the prebuilt ESM bundle. Published files under dist/ and src/ are also available through matching package subpaths.
import { useEffect, useState } from '@fr0st/state';
const first = useState('Ada');
const last = useState('Lovelace');
useEffect(() => {
console.log(`${first()} ${last()}`);
});
last('Byron');
first.value = 'Augusta'; // logs "Augusta Byron" once on the next microtaskimport { StateStore, useEffect } from '@fr0st/state';
const store = StateStore.wrap({
count: 0,
});
useEffect(() => {
console.log('count =', store.count);
});
store.count = 1; // logs "count = 1" on the next microtaskTypeScript note: Frost State is written in JavaScript and uses JSDoc types, which most editors surface as IntelliSense.
Frost State exports three named APIs from @fr0st/state: useState, useEffect, and StateStore.
Creates a callable state accessor for a single value.
const state = useState(value);The returned accessor supports:
state(): read the current valuestate(next): write the current valuestate.get(markEffects = true): read the current value, optionally without effect trackingstate.set(next): write the current valuestate.value: read or write the current valuestate.previous: read the previous value after the last successful change; initiallyundefined
Writes use Object.is to detect changes. Writing the same value leaves previous
unchanged and does not schedule effects. Mutating an object or array in place does
not trigger an update; assign a different reference to notify effects.
import { useState } from '@fr0st/state';
const state = useState('hello');
state(); // 'hello'
state('world');
state.get(); // 'world'
state.set('again');
state.value = 'done';
state.previous; // 'again'Runs an effect immediately, tracks the states read synchronously during that run,
and schedules re-runs when any of those states change. Reads after an await or
inside a later callback are not tracked by that run.
const effect = useEffect(callback, options);Options:
options.weak(defaultfalse): use aWeakRef-backed runner
With weak: true, keep a reference to the returned runner for as long as the effect
should remain active. Otherwise, it may be garbage-collected.
The returned runner supports:
effect(): schedule a coalesced re-run in a microtaskeffect.sync(): run immediately and cancel any pending re-runeffect.stop(): stop the effect, cancel pending work, and unsubscribe
import { useEffect, useState } from '@fr0st/state';
const a = useState(1);
const b = useState(2);
const effect = useEffect(() => {
console.log(a() + b());
});
a(3);
effect.sync(); // logs 5 immediately and cancels the pending microtask
effect.stop();Creates a callable, proxy-backed keyed store for state accessors. Property reads
return stored values, property assignment writes keys, and missing string-key reads
return undefined. Effects that read missing keys subscribe to later value changes
without exposing those keys through enumeration.
const store = new StateStore();
const state = store(key, defaultValue);The returned store supports:
store.key: read an existing keystore.key = value: write a keydelete store.key: remove a key and reset its accessor toundefinedstore.use(key, defaultValue): retrieve or create a state accessorstore(key, defaultValue): retrieve or create a state accessor through the callable formstore(): return the store itselfstore.set(object): set top-level keys from an objectstore.has(key): check whether a key existsstore.keys(): iterate stored keys
State keys are strings. Defaults apply when a key is created or restored after
deletion; existing keys retain their values. Symbol properties are ordinary,
nonreactive properties and are excluded from store.keys().
import { StateStore, useEffect } from '@fr0st/state';
const store = new StateStore();
const count = store('count', 0);
store.set({ label: 'Clicks' });
useEffect(() => {
console.log(store.label, count());
});
count(1);
store.count = 2; // logs "Clicks 2" once on the next microtask
store.has('count'); // true
Array.from(store.keys()); // ['count', 'label']Deletion preserves the accessor so effects and previously returned accessors stay connected to the key:
import { StateStore } from '@fr0st/state';
const store = StateStore.wrap({ count: 1 });
const count = store('count');
delete store.count;
store.has('count'); // false
count(); // undefined
count(2); // restores the key
store.count; // 2
store.has('count'); // trueString state keys support data-property definitions with configurable,
enumerable, and writable all enabled. New keys must explicitly enable these
attributes; updates may omit unchanged attributes:
import { StateStore } from '@fr0st/state';
const store = new StateStore();
Object.defineProperty(store, 'count', {
value: 1,
configurable: true,
enumerable: true,
writable: true,
});
Object.defineProperty(store, 'count', { value: 2 }); // notifies effects normally
store.count; // 2StateStore.wrap(value, options): wrap a plain object in a storeStateStore.merge(store, value, options): merge plain-object data into a store
Both helpers accept options.deep (default false) to process nested plain objects.
wrap returns an existing StateStore unchanged. For plain-object data, merge
updates and returns the target store. Non-plain input values are returned unchanged
by either helper; merge leaves the target unchanged in that case.
merge requires a StateStore target by default. Set options.allowFallback to
true to call wrap(value, options) when the target is not a store.
import { StateStore } from '@fr0st/state';
const nested = StateStore.wrap(
{
user: {
name: 'Ada',
},
},
{ deep: true },
);
nested.user.name = 'Grace';
const settings = new StateStore();
StateStore.merge(
settings,
{
ui: {
theme: 'dark',
},
},
{ deep: true },
);
StateStore.merge(
settings,
{
ui: {
compact: true,
},
},
{ deep: true },
);
settings.ui.theme = 'light';
nested.user.name; // 'Grace'
settings.ui.theme; // 'light'
settings.ui.compact; // trueDeep wrapping and merging preserve cycles and shared plain-object references.
useEffect()tracks only the states read synchronously during the latest successful run. A failed rerun retains the previous subscriptions; a failed initial run releases them.useEffect()coalesces normal re-runs in a microtask.effect.sync()runs immediately and cancels a pending re-run.effect.stop()permanently cancels the effect and releases its subscriptions.store.set(...)assigns own enumerable string keys at the top level only. Nested plain objects remain plain values.- Use
StateStore.wrap(..., { deep: true })orStateStore.merge(..., { deep: true })for nested reactive stores. - Deep wrap and merge preserve cycles and shared references in the incoming plain-object data.
- Deep merge separates existing shared stores when distinct incoming objects update them, preserving their pre-merge values in each branch.
- Arrays, dates, class instances, and null-prototype objects are treated as plain values rather than nested stores.
- Missing property reads such as
store.missingreturnundefined. Reads made during effect tracking still subscribe to later value changes without exposing the key. store.has(key),key in store,store.keys(), andObject.keys(store)do not create effect subscriptions. Read a key's value to track it.- Deleting a key resets its accessor to
undefinedand hides the key. Effects are scheduled when that changes the value. Writing through a previously returned accessor restores the key, including writes ofundefined. - For string state keys, accessor properties and restrictive descriptors are rejected without changing the key.
Object.definePropertythrows;Reflect.definePropertyreturnsfalse. - Stores must remain extensible.
Object.preventExtensions,Object.seal, andObject.freezethrow without changing the store.Reflect.preventExtensionsreturnsfalse. constructor,use,set,has,keys,arguments,caller, andprototypeare reserved and cannot be used as state keys.nameandlengthare valid state keys.- Callable stores work with string code generation disabled.
- Weak effects rely on
WeakRef.
npm test
npm run lint
npm run buildFrost State is released under the MIT License.