Comparison

The same todo store in each library, trimmed to what each section compares.

Feature matrix

Kin StoreZustandRedux / RTKJotaiMobX
Bundle size1.6 KB389 B17.5 KB4.0 KB15.6 KB
Zero dependencies✅✅❌✅✅
Tiny footprint✅✅❌✅❌
100% type-safe✅
⚠️
⚠️
✅✅
Low boilerplate✅
⚠️
❌
⚠️
⚠️
Linear plugin composition✅❌❌
—
—
Separate state and logic✅
❌
✅
—
✅
Opt-in complexity✅✅❌
⚠️
❌
No hidden magic✅✅✅✅
❌
Reactive composition✅
⚠️
❌✅✅

✅ full support · ⚠️ partial or conditional · — not applicable (different model)

Bundle sizes are each library's full package import, bundled with rolldown, minified, and gzipped. Tree-shaking down to only the APIs you use will land smaller across the board.

vs Zustand

Kin Store keeps state and behavior structurally separate and infers types without an annotation. Zustand keeps both in one object, so the type can't say which is data and which is behavior, and it infers any/unknown if the annotation on create<State>() (or the innermost middleware call) is missing.

Kin Store's plugins read top-to-bottom, one .use() per capability. Zustand's middleware nests and reads inside-out, so persist, devtools, and immer together are three levels deep. Each middleware can also change the store's API shape (immer changes what setState accepts), so composition order matters.

import { history, immer, persist } from "@kintools/store-plugins";
import { createStore, useSelector } from "@kintools/store-react";
 
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
 
// Read top-to-bottom: one .use() per plugin, no nesting.
const todoStore = createStore({ todos: [], status: "idle" } as TodoState)
  .use("persist", persist({ key: "todos" }))
  .use("history", history())
  .use(
    immer({
      addTodo(text: string): void {
        this.set((draft) => {
          draft.todos.push({ id: Date.now(), text, done: false });
        });
      },
    }),
  );
 
// Namespaced plugins never collide.
await todoStore.persist.hydrate();
todoStore.history.undo();
 
// Methods are stable refs, not part of the state subscription.
function TodoApp() {
  const todos = useSelector(todoStore, (s) => s.todos);
  return <button onClick={() => todoStore.addTodo("new")}>Add</button>;
}

What's different:

Kin StoreZustand
Extension modeldeclarative object: methods, lifecycle hooksimperative wrapper: may alter set, get, or the store shape
Adding a plugin.use(...)wrap the whole store again
Reading ordertop-to-bottominside-out
State vs actionsstructurally separatesame object
Call logic in Reactcall directly, no hookhook required, subscribes even to stable action refs

Writing extensions

A Kin Store plugin is a declarative object: the methods and lifecycle hooks it contributes, with no runtime patching.

A Zustand middleware implements StateCreator directly: receive (fn, set, get, api), patch api, then call fn(set, get, api). An undo/redo middleware therefore needs a declare module augmentation, a mutation of api as any, and an as unknown as History cast, since the types can't follow the runtime mutation.

import type { NestedMethods, StorePlugin } from "@kintools/store-core";
 
type HistoryMethods = {
  canUndo(): boolean;
  canRedo(): boolean;
  undo(): boolean;
  redo(): boolean;
};
 
// TState flows through every type position, no `any`.
export function history<
  TState,
  TStoreMethods extends NestedMethods,
  TNamespace extends string | undefined,
>(): StorePlugin<TState, TStoreMethods, TNamespace, HistoryMethods> {
  const snapshots: TState[] = [];
  let index = 0;
  let isRestoring = false;
 
  return {
    onActivated() {
      snapshots.push(this.get());
      this.subscribe(() => {
        if (isRestoring) return;
        snapshots.length = index + 1;
        snapshots.push(this.get());
        index = snapshots.length - 1;
      });
    },
 
    canUndo: () => index > 0,
    canRedo: () => index + 1 < snapshots.length,
    undo(): boolean {
      if (index <= 0) return false;
      isRestoring = true;
      this.set(snapshots[--index]);
      isRestoring = false;
      return true;
    },
    redo(): boolean {
      if (index + 1 >= snapshots.length) return false;
      isRestoring = true;
      this.set(snapshots[++index]);
      isRestoring = false;
      return true;
    },
  };
}

What's different:

Kin Store pluginZustand middleware
Type extensionStorePlugin genericsdeclare module augmentation + as unknown as History
Expose methodsnamed methods on a plain objectmutate api as any
Restore statethis.set(), runs through every listenerapi.setState(saved, true), bypasses all middlewares
Name collisionthrows at registration timesilent overwrite

WARNING

Plugins can reach the whole store through this, but patching the store object itself is discouraged. Declare capabilities as named methods instead.

vs Redux / RTK

Kin Store keeps sync and async state changes in one flat model: methods, fully inferred, with no manual type exports. Redux splits the same logic across a thunk and a slice's extraReducers, and needs RootState / AppDispatch exported by hand for types to flow through call sites.

import { createStore } from "@kintools/store-core";
 
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
 
// Methods are fully inferred. Each one makes a single change, like a reducer.
const todoStore = createStore<TodoState>({ todos: [], status: "idle" }).use({
  addTodo(text: string): void {
    this.merge((state) => ({
      todos: [...state.todos, { id: Date.now(), text, done: false }],
    }));
  },
  fetchStarted(): void {
    this.merge({ status: "loading" });
  },
  fetchSucceeded(todos: Todo[]): void {
    this.set({ todos, status: "idle" });
  },
  fetchFailed(): void {
    this.merge({ status: "failed" });
  },
 
  // Orchestrates only, no direct set/merge.
  async fetchTodos(): Promise<void> {
    this.fetchStarted();
    try {
      const resp = await fetch("/api/todos");
      this.fetchSucceeded((await resp.json()) as Todo[]);
    } catch {
      this.fetchFailed();
    }
  },
});
 
todoStore.addTodo("Buy milk");
await todoStore.fetchTodos();

What's different:

Kin StoreRedux / RTK
Async actionsmethods that call set/mergecreateAsyncThunk + extraReducers
Type exportsfully inferred, zero exportsRootState, AppDispatch manual exports
Access patternstore.addTodo(...)slice.actions.addTodo(...)
Call logic in Reactcall directly, no hookuseDispatch() hook required

Kin Store methods don't cancel superseded concurrent calls (Zustand's don't either), unlike Redux-Saga's takeLatest; see Guarding against race conditions for the manual pattern.

Writing extensions

A Kin Store plugin only lists the methods and hooks it contributes, which is what keeps it type-safe without any and lets .use() throw on a name conflict. Redux enhancers, like Zustand middleware, are imperative wrappers around the store factory that can reshape any part of the store API.

import type { NestedMethods, StorePlugin } from "@kintools/store-core";
 
type HistoryMethods = {
  canUndo(): boolean;
  canRedo(): boolean;
  undo(): boolean;
  redo(): boolean;
};
 
// TState flows through every type position, no `any`.
export function history<
  TState,
  TStoreMethods extends NestedMethods,
  TNamespace extends string | undefined,
>(): StorePlugin<TState, TStoreMethods, TNamespace, HistoryMethods> {
  const snapshots: TState[] = [];
  let index = 0;
  let isRestoring = false;
 
  return {
    onActivated() {
      snapshots.push(this.get());
      this.subscribe(() => {
        if (isRestoring) return;
        snapshots.length = index + 1;
        snapshots.push(this.get());
        index = snapshots.length - 1;
      });
    },
 
    canUndo: () => index > 0,
    canRedo: () => index + 1 < snapshots.length,
    undo(): boolean {
      if (index <= 0) return false;
      isRestoring = true;
      this.set(snapshots[--index]);
      isRestoring = false;
      return true;
    },
    redo(): boolean {
      if (index + 1 >= snapshots.length) return false;
      isRestoring = true;
      this.set(snapshots[++index]);
      isRestoring = false;
      return true;
    },
  };
}

vs Jotai

Kin Store's stores hold a value and its subscribers, and app logic is plain functions or methods. Jotai is atom-based: each piece of state is its own atom, and derived atoms compose them. That's a different model, not a worse one, but it means thinking in atoms rather than domains, and app logic has to be wrapped in an atom (atom(null, (get, set, arg) => ...)).

Kin Store stores can be read and written anywhere with get() and set(); useStore is only for components that should re-render. Jotai's reading (useAtomValue) and writing (useSetAtom) are both hook-bound in React. Outside React, jotai/vanilla or getDefaultStore() gives a { get, set, sub } interface, but it's a separate path. When a write atom throws, the stack trace points at the useSetAtom call site rather than the atom definition, so a chain of atoms is hard to follow in a debugger.

import { createStore, useStore } from "@kintools/store-react";
 
type Todo = { id: number; text: string; done: boolean };
 
// One store per field.
const todosStore = createStore<Todo[]>([]);
const statusStore = createStore<"idle" | "loading" | "failed">("idle");
 
// App logic can be plain top-level functions.
function addTodo(text: string): void {
  todosStore.set((prev) => [...prev, { id: Date.now(), text, done: false }]);
}
 
async function fetchTodos(): Promise<void> {
  statusStore.set("loading");
  try {
    const todos = (await fetch("/api/todos").then((r) => r.json())) as Todo[];
    todosStore.set(todos);
    statusStore.set("idle");
  } catch {
    statusStore.set("failed");
  }
}
 
function TodoApp() {
  const todos = useStore(todosStore);
  const status = useStore(statusStore);
 
  // addTodo and fetchTodos are callable directly, no hooks.
 
  // ...
}

What's different:

Kin StoreJotai
State modelstores (value + subscribers)atoms
App logicplain functions / methodswrapped in atoms
Read / write outside Reactyes: get(), set() and plain functionsjotai/vanilla or getDefaultStore()
Reactive compositionderive((get) => ...)derived atoms

vs MobX

Kin Store's reactivity is explicit: state changes only through set, merge, or a method calling one of them, and a component re-renders only because it called useStore/useSelector itself. MobX's makeAutoObservable instruments every property and method implicitly, so mutations just work, at a cost: async methods need runInAction, and every component reading observable state needs observer(). Forgetting either fails silently (stale data, no error). At 15.6 KB gzipped, MobX is also one of the heaviest libraries here, behind only Redux/RTK.

import { createStore, useSelector } from "@kintools/store-react";
 
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
 
// A plain object: no class, no proxy, no instrumentation.
const todoStore = createStore<TodoState>({ todos: [], status: "idle" })
  .use({
    addTodo(text: string): void {
      this.merge((s) => ({
        todos: [...s.todos, { id: Date.now(), text, done: false }],
      }));
    },
    async fetchTodos(): Promise<void> {
      this.merge({ status: "loading" });
      try {
        const resp = await fetch("/api/todos");
        const todos = (await resp.json()) as Todo[];
        // set is always safe after await.
        this.set({ todos, status: "idle" });
      } catch {
        this.merge({ status: "failed" });
      }
    },
  });
 
// No observer() wrapper: subscriptions are opt-in and explicit.
function TodoApp() {
  const todos = useSelector(todoStore, (s) => s.todos);
  return <button onClick={() => todoStore.addTodo("Buy milk")}>Add</button>;
}

What's different:

Kin StoreMobX
State mutationsset/merge, no proxymutable (proxy-intercepted)
Async updatesset/merge after await, no wrappermust wrap in runInAction
Read state in ReactuseStore, useSelector only where neededobserver() on every component
Class requiredno, plain objectyes (or observable({...}))
Reactive graphexplicit via deriveimplicit, auto-tracked