@ilokesto/state

**English** | 한국어
1,258
Install
npm install @ilokesto/state
Latest Version:2.0.1
Vue:3
License:MIT
Last Updated:Oct 9, 2026
Links: npm
Maintainer: ayden94

@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

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, and devtools
  • 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.is semantics.
  • Object, array, Map, Set, and Date selections use the package's one-level shallow comparison.
  • 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 use Readonly<T> for object state. Callable state remains exact T, 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

  • pipe to compose a store from plain state and registered middleware
  • definePipeableMiddleware() 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 optional immer peer 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: deepCompare could not handle Map, Set, or Date correctly, and would stack-overflow on circular references. Shallow comparison handles Map, 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

mini-rx-store

MiniRx: The Lightweight RxJS Redux Store

19,795 169
nanostores

A tiny (351 bytes) state manager for React/Preact/Vue/Svelte with many atomic tr...

202,069,944 7,630
nanostores-cjs

A tiny (266 bytes) state manager for React/Preact/Vue/Svelte with many atomic tr...

11,648 7,630
@symbiote-native/slider

Third-party native Slider view wrapped for SymbioteNative — one framework-agnost...

5,749 72

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