@ilokesto/state
@ilokesto/state
English | 한국어
A small multi-framework state helper built on top of @ilokesto/store.
This package keeps the store core framework-agnostic and exposes thin adapters for React, Vue, Angular, Svelte, and Solid.
Documentation
- Full docs: English · 한국어
- Source: packages/state
- npm: @ilokesto/state
Create once, bind explicitly
@ilokesto/state now exports vanilla createStore, createReducer, composition, and structural store contracts. Each framework exports bind(store) and bindReducer(handle); existing create convenience APIs use the same foundation.
import { createStore } from '@ilokesto/state';
import { bind } from '@ilokesto/state/react';
const counter = createStore({ count: 0 });
const useCounter = bind(counter);
counter.update(previous => ({ count: previous.count + 1 }));
See vanilla state and binding and the major migration guide. After building, run node packages/state/examples/vanilla-counter.mjs from the workspace root.
Features
- Create framework-friendly state adapters from plain state or a reducer
- Subscribe to slices with selectors using one shared shallow-equality contract
- Read state outside framework lifecycles with
readOnly() - Update state outside framework lifecycles with
writeOnly() - Compose stores with middleware like
logger,debounce,persist, anddevtools - Use
adaptor()for immer-based object updates
Installation
Install the package plus the framework you want to use.
pnpm add @ilokesto/state
immer is an optional peer dependency and is only needed when you use adaptor().
Selector Subscription Contract
React, Vue, Angular, Svelte, and Solid use the same selector subscription behavior for both plain and reducer state:
- Primitive selections use
Object.issemantics. - Object, array,
Map,Set, andDateselections use the package's one-levelshallowcomparison. - An update that changes the store but leaves the selected value shallow-equal does not notify the framework consumer.
- A relevant update notifies the consumer exactly once.
- Subscriptions are removed by the framework lifecycle: React unmount, Vue scope disposal, Angular
DestroyRef, Solid owner cleanup, or Svelte unsubscribe. - React's server snapshot selects from the store's initial state, preserving hydration semantics even if the current state has already changed.
- Selector inputs, full-state reactive results, and
readOnly()snapshots useReadonly<T>for object state. Callable state remains exactT, preserving arbitrary generic and overloaded signatures; its own-property modifiers remain as declared. Plain-state writers retain mutable next-state and updater contracts, while reducer writers accept typed actions.
This contract is built on Store.subscribeSelector; adapters do not expose framework-specific equality options. It applies equally to create(initialState) and create(reducer, initialState).
React
import { create } from '@ilokesto/state/react';
type CounterState = {
count: number;
};
const useCounter = create<CounterState>({ count: 0 });
function Counter() {
const [count, setCounter] = useCounter((state) => state.count);
return (
<button onClick={() => setCounter((prev) => ({ ...prev, count: prev.count + 1 }))}>
{count}
</button>
);
}
Vue
import { create } from '@ilokesto/state/vue';
type CounterState = {
count: number;
};
const useCounter = create<CounterState>({ count: 0 });
export function useCounterState() {
const { state, setState } = useCounter((current) => current.count);
return {
count: state,
increment: () => setState((prev) => ({ ...prev, count: prev.count + 1 })),
};
}
The returned composable must run inside setup() or an active effectScope().
Angular
import { Component, DestroyRef, inject } from '@angular/core';
import { create } from '@ilokesto/state/angular';
type CounterState = {
count: number;
};
const counter = create<CounterState>({ count: 0 });
@Component({
selector: 'app-counter',
standalone: true,
template: '<button (click)="increment()">{{ count() }}</button>',
})
export class CounterComponent {
private readonly destroyRef = inject(DestroyRef);
readonly count = counter((state) => state.count, { destroyRef }).state;
increment() {
counter.writeOnly()((prev) => ({ ...prev, count: prev.count + 1 }));
}
}
If you create an Angular signal from this adapter outside an active injection context, pass { destroyRef } explicitly.
Svelte
import { create } from '@ilokesto/state/svelte';
type CounterState = {
count: number;
};
export const counter = create<CounterState>({ count: 0 });
export const count = counter.select((state) => state.count);
<script lang="ts">
import { counter, count } from './counter';
const increment = () => {
counter.update((state) => ({ ...state, count: state.count + 1 }));
};
</script>
<button on:click={increment}>{$count}</button>
Solid
import { create } from '@ilokesto/state/solid';
type CounterState = {
count: number;
};
const useCounter = create<CounterState>({ count: 0 });
function Counter() {
const { state, setState } = useCounter((current) => current.count);
return (
<button onClick={() => setState((prev) => ({ ...prev, count: prev.count + 1 }))}>
{state()}
</button>
);
}
Call the function returned by create() inside a reactive owner such as a component or createRoot(). Use readOnly() for synchronous reads outside Solid scope.
Reducer Usage
All framework adapters accept the reducer form:
type CounterState = {
count: number;
};
type CounterAction = { type: 'increment' } | { type: 'decrement' };
const counter = create<CounterState, CounterAction>(
(state, action) => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
},
{ count: 0 },
);
React returns tuples, Vue returns { state, dispatch }, Angular returns { state, dispatch }, Svelte returns a readable store with dispatch, and Solid returns { state, dispatch }.
Reusing an Existing Store
Pass an existing Store as the second argument to share one state snapshot across framework adapters and direct Store writes. Reusing that Store with the exact same reducer function reference is idempotent: @ilokesto/state installs reducer middleware once, and every adapter observes the result of one reducer call per dispatch.
A Store can have only one reducer identity. Registering a different reducer function on the same Store throws synchronously with TypeError: Cannot register a different reducer for the same Store. before middleware or state is changed. Direct store.setState(...) writes remain plain Store updates and do not invoke the reducer.
Read and Write Outside Framework Lifecycles
const writeCounter = counter.writeOnly();
const currentCount = counter.readOnly((state) => state.count);
writeCounter((prev) => ({ ...prev, count: prev.count + 1 }));
console.log(currentCount);
Middleware and Utilities
@ilokesto/state/middleware
debounce()devtools()history()logger()persist()throttle()validate()
@ilokesto/state/utils
pipeto compose a store from plain state and registered middlewaredefinePipeableMiddleware()to register custom pipe middleware metadata
@ilokesto/state/adaptor
adaptor()to create immutable object updaters with immer
pipe is builder-only. Start with pipe.use(...), add middleware in outer-to-inner order, then call .create(initialState). The first .use() is outermost during updates, while middleware setup runs left to right when .create() creates the Store.
import { create } from '@ilokesto/state/react';
import { jsonStorage, logger, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';
const decodeCounter = (value: unknown): { readonly count: number } | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('count' in value) || typeof value.count !== 'number') return null;
return { count: value.count };
};
const counterStore = pipe
.use(logger({ timestamp: true }))
.use(persist({ key: 'counter', storage: jsonStorage(() => window.localStorage), decode: decodeCounter }))
.create({ count: 0 });
export const useCounter = create(counterStore);
export async function restoreCounter() {
await counterStore.persist.rehydrate();
}
.create() accepts plain state only. It does not accept an existing Store.
History, timing, and disposal
history() records successful synchronous state changes. It adds undo(), redo(),
canUndo(), canRedo(), and clearHistory() to the returned store. undo() and redo()
apply a recorded state through the store, so compatible middleware can observe those changes.
import { history, logger } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';
const counterStore = pipe
.use(logger())
.use(history({ limit: 20 }))
.create({ count: 0 });
counterStore.undo();
history() cannot share a pipe chain with debounce() or throttle(), in either declaration
order. Delayed updates do not provide the synchronous commit boundary history needs. Pipe rejects
that conflict and never reorders middleware to make a chain valid.
debounce() coalesces updates into one commit after its wait period, which defaults to 300
milliseconds. Its wait must be finite and non-negative; 0 is valid. Invalid waits throw
RangeError before middleware or timer setup.
throttle() uses leading-drop behavior: the first update passes through immediately, then later
updates are dropped until the wait period ends.
import { logger, throttle } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';
const counterStore = pipe
.use(logger())
.use(throttle(250))
.create({ count: 0 });
Timer-based middleware registers cleanup with its store. Call dispose(store) when a store is no
longer needed to cancel pending work and release middleware-owned resources. Disposal is scoped to
that store and may be called again safely.
import { dispose, throttle } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';
const counterStore = pipe.use(throttle(250)).create({ count: 0 });
dispose(counterStore);
Safe persistence
persist requires key, a storage factory, and decode. Creation performs no storage I/O.
Call await store.persist.rehydrate() explicitly in client startup or a client effect.
Restoration reads the envelope, migrates, decodes, validates, and commits immediately.
Invalid data, failed migrations, and future versions reject restoration while preserving
the current memory and stored value. Successful migrations queue a write with the current
version; current-version payloads are decoded without a rewrite.
import { indexedDBStorage, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';
type CounterState = { readonly count: number };
const decodeCounter = (value: unknown): CounterState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('count' in value) || typeof value.count !== 'number') return null;
return { count: value.count };
};
const counterStore = pipe
.use(persist({
key: 'counter',
storage: indexedDBStorage({ database: 'my-app-state' }),
decode: decodeCounter,
}))
.create<CounterState>({ count: 0 });
await counterStore.persist.rehydrate();
counterStore.setState({ count: 1 });
await counterStore.persist.flush();
decode is required. Stored values are never trusted without validation.
IndexedDB preserves native File, Blob, Map, Set, Date, and ArrayBuffer
through structured clone, without base64 conversion. jsonStorage(() => window.localStorage)
and jsonStorage(() => window.sessionStorage) support JSON values only; cookieStorage({ path: '/' })
is also JSON-based. There is no automatic fallback from IndexedDB to another adapter.
Pass helper results directly, not wrapped in another () =>.
This persistence API is a breaking change released as a patch, an explicit exception for the v2 correction in issue #102.
| Old API | Replacement |
|---|---|
local, session, cookie options |
key + jsonStorage(...) or cookieStorage(...) |
Eager hydration / skipHydration |
Explicit await store.persist.rehydrate() |
hasHydrated() / onRehydrateStorage |
getStatus() / subscribe(listener) |
Synchronous rehydrate() / clearStorage() |
Await their promises |
| Assuming an update is saved | await store.persist.flush() |
Edits before restoration completes produce a conflict; choose
rehydrate({ conflict: 'keep-current' }) or rehydrate({ conflict: 'use-stored' })
explicitly. No deep merge occurs. Observe hydration, saving, pending work, and errors
through getStatus() and subscribe(). clearStorage() deletes only the configured key,
leaves memory unchanged, and orders deletion after older writes.
Factories create independent instances for each store. dispose(store) cleans up only
that store's pending work and owned connection; await flush() first when saving before
disposal is required. IndexedDB saving completes at transaction completion, not request
success. flush() covers its call-time target and does not advance debounce timers.
See the persistence migration guide.
After building, run node packages/state/examples/persist-lifecycle.mjs for a complete
custom storage factory example with restoration, saving, and explicit conflict resolution.
Every middleware passed to .use() must be registered with definePipeableMiddleware, including custom middleware. Its metadata requires an id; duplicate IDs reject by default. Set duplicate: 'allow' on every occurrence only when repetition is intentional.
before: ['id'] means this middleware must be declared earlier, which makes it outer. after: ['id'] means it must be declared later, which makes it inner. A relation to a middleware that is absent is ignored. Pipe rejects invalid present relationships and cycles, and it never reorders middleware for you.
Declare debounce() before persist(); the reverse order remains rejected with
MIDDLEWARE_ORDER. Persistence observes actual commits, including delayed commits.
Restoration bypasses timing middleware and establishes a new history baseline.
The history() + debounce()/throttle() restriction still applies.
Capabilities make a middleware's Store additions visible to later middleware and the final Store. requires must already be available when .use() runs, so an earlier outer middleware cannot require a capability supplied by a later inner middleware. adds supplies capabilities in the immediate outer-to-inner direction.
import { definePipeableMiddleware, pipe } from '@ilokesto/state/utils';
import type { PipeAnyMiddleware, PipeCapability } from '@ilokesto/state/utils';
type IncrementCapability = PipeCapability<
'@example/increment',
{ readonly increment: () => void }
>;
const incrementCapability = {
id: '@example/increment',
shape: { increment: (): void => undefined },
} as const satisfies IncrementCapability;
const addIncrement: PipeAnyMiddleware<readonly [], readonly [IncrementCapability]> = (store) => {
return Object.assign(store, incrementCapability.shape);
};
const incrementMiddleware = definePipeableMiddleware(addIncrement, {
adds: [incrementCapability],
id: '@example/increment-middleware',
} as const);
const counterStore = pipe.use(incrementMiddleware).create({ count: 0 });
counterStore.increment();
counterStore.getState().count;
This is a breaking change. Callable and variadic pipe syntax has been removed. Replace legacy calls with pipe.use(...).create(initialState).
Exports
@ilokesto/state/react→ React adapter@ilokesto/state/vue→ Vue adapter@ilokesto/state/angular→ Angular adapter@ilokesto/state/svelte→ Svelte adapter@ilokesto/state/solid→ Solid adapter@ilokesto/state/middleware→ middleware helpers@ilokesto/state/utils→pipe,definePipeableMiddleware, and pipe types@ilokesto/state/adaptor→adaptor(requires the optionalimmerpeer dependency)
Migration: unified shallow selector subscriptions
What changed
The React adapter previously used deep comparison (deepCompare) to determine whether a selector result should trigger a re-render. It switched to shallow comparison, matching the pattern used by zustand. React, Vue, Angular, Svelte, and Solid now all use that same shallow selector subscription contract for plain and reducer state.
Why
- Performance: deep comparison ran on every render, recursively traversing the entire state. Shallow comparison checks one level only.
- Correctness:
deepComparecould not handleMap,Set, orDatecorrectly, and would stack-overflow on circular references. Shallow comparison handlesMap,Set, arrays, and plain objects correctly, and is inherently safe against circular references. - Ecosystem alignment: zustand v5 uses shallow comparison as the standard pattern.
What this means for you
| Pattern | Before (deep) | After (shallow) |
|---|---|---|
useStore(s => s.count) |
Works | Works (same) |
useStore(s => ({ a: s.a, b: s.b })) |
Deep-compared (always equal if values match) | Shallow-compared (equal if 1st-level values match) |
| Nested object in selector result | Deep-compared | Reference-compared (Object.is) |
Map / Set in state |
Incorrect comparison | Correct shallow comparison |
Date in state |
Incorrect comparison | Correct shallow comparison via getTime() |
If you need deep comparison
Use useMemo to memoize your selector result:
const value = useMemo(() => {
return computeDerivedState(store.getState());
}, [dependency]);
Or return a primitive from your selector so Object.is is sufficient:
const time = useStore(s => s.date.getTime());
Keep selector identity stable
The shallow selector cache is keyed on the selector function identity. An inline selector
(e.g. useStore(s => ({ a: s.a, b: s.b }))) creates a new function reference on every render,
which resets the cache and defeats the shallow optimization. Prefer one of:
// 1. Module-scope selector (preferred for pure derivations)
const selectSlice = (s: State) => ({ a: s.a, b: s.b });
const slice = useStore(selectSlice);
// 2. useCallback when the selector depends on props or other reactive inputs
const selectFiltered = useCallback(
(s: State) => s.items.filter(i => i.id === activeId),
[activeId],
);
const filtered = useStore(selectFiltered);
Returning a new object/array literal inside an inline selector also produces a new reference
each call; the shallow comparison still avoids a re-render when first-level values match, but
stabilizing the selector identity lets useSyncExternalStore skip the comparison entirely.
Development
pnpm install
pnpm build
Build outputs are generated in the dist directory.
License
MIT
Related Packages
A tiny (351 bytes) state manager for React/Preact/Vue/Svelte with many atomic tr...
A tiny (266 bytes) state manager for React/Preact/Vue/Svelte with many atomic tr...
Third-party native Slider view wrapped for SymbioteNative — one framework-agnost...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 2.0.1 | ^3.0.0 | MIT | |
| 2.0.0 | ^3.0.0 | MIT | |
| 1.0.4 | ^3.0.0 | MIT | |
| 1.0.3 | MIT | ||
| 1.0.2 | MIT | ||
| 1.0.1 | MIT | ||
| 1.0.0 | MIT |