Comparison

The same todo store — { todos, status } with addTodo and fetchTodos — implemented in each library. Full, working setup in every example.

Feature matrix

Kin StoreZustandRedux / RTKJotaiMobX
Bundle size2.0 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.

Kin Store is new: this table is accurate today, but Redux, Zustand, Jotai, and MobX all carry years of production use this library doesn't have yet. Try it, and tell us where it breaks.

vs Redux / RTK

Kin Store keeps sync and async state changes in one flat model: reducers for the state change, methods for orchestration, both fully inferred with no manual type exports. Redux splits that 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 { withPlugins } from "@kintools/store-core";
 
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
 
// Sync and async live side-by-side — reducers for state changes,
// methods for orchestration. No separate thunk concept.
const todoStore = withPlugins<TodoState>({ todos: [], status: "idle" }).use({
  reducers: {
    addTodo: (state, text: string) => ({
      ...state,
      todos: [...state.todos, { id: Date.now(), text, done: false }],
    }),
    fetchStart: (state) => ({ ...state, status: "loading" }),
    fetchFulfilled: (state, todos: Todo[]) => ({ todos, status: "idle" }),
    fetchRejected: (state) => ({ ...state, status: "failed" }),
  },
  middleware: () => (ctx, next) => {
    console.log("dispatching", ctx.reducer.name, ctx.reducer.args);
    return next();
  },
  methods: (store) => ({
    async fetchTodos(): Promise<void> {
      store.dispatch.fetchStart();
      try {
        const resp = await fetch("/api/todos");
        const todos = (await resp.json()) as Todo[];
        store.dispatch.fetchFulfilled(todos);
      } catch {
        store.dispatch.fetchRejected();
      }
    },
  }),
});
 
// Fully typed — no manual type exports needed.
todoStore.dispatch.addTodo("Buy groceries");
await todoStore.fetchTodos();

What's different:

Kin StoreRedux / RTK
Async actionsMethod that calls reducerscreateAsyncThunk + extraReducers
Middleware(ctx, next) => ...(api) => (next) => (action) => ...
Type exportsFully inferred — zero exportsRootState, AppDispatch manual exports
Access patternstore.dispatch.addTodo(...)slice.actions.addTodo(...)
Call logic in ReactCall directly — no hookuseDispatch() hook required

Redux-Saga's takeLatest sequences and cancels concurrent calls to the same action for you; Kin Store's methods don't, the same tradeoff Zustand makes. See Guarding against race conditions for the manual pattern.

Writing extensions

The fundamental difference is model: Redux enhancers (and Zustand middleware) are imperative wrappers — functions that intercept the store factory and may freely reshape any part of the store API. A Kin Store plugin is a declarative object: it lists what it contributes (reducers, middleware, methods, lifecycle hooks) and nothing more. That constraint is what makes plugins fully type-safe without any, and registration safe — the runtime validates names at .use() time and throws on conflict.

import { getPluginDispatch } from "@kintools/store-core";
import type {
  InferActions,
  NestedMethods,
  NestedReducers,
  StorePlugin,
} from "@kintools/store-core";
 
type HistoryReducers<TState> = {
  _restore: (state: TState, saved: TState) => TState;
};
type HistoryMethods = {
  canUndo(): boolean;
  canRedo(): boolean;
  undo(): boolean;
  redo(): boolean;
};
 
// TState flows through every type position — no any needed.
export function history<
  TState,
  TStoreReducers extends NestedReducers<TState>,
  TStoreMethods extends NestedMethods,
  TNamespace extends string | undefined,
>(): StorePlugin<
  TState,
  TStoreReducers,
  TStoreMethods,
  TNamespace,
  HistoryReducers<TState>,
  HistoryMethods
> {
  const snapshots: TState[] = [];
  let index = 0;
  let isRestoring = false;
 
  return {
    reducers: {
      // A declared reducer, not a hidden action type — visible in devtools.
      _restore: (_state, saved: TState) => saved,
    },
 
    methods: (store, { namespace }) => {
      const dispatch = getPluginDispatch(store, namespace);
 
      function restore(state: TState): void {
        isRestoring = true;
        dispatch._restore(state); // Fully typed.
        isRestoring = false;
      }
 
      return {
        canUndo: () => index > 0,
        canRedo: () => index + 1 < snapshots.length,
        undo(): boolean {
          if (index <= 0) return false;
          restore(snapshots[--index]);
          return true;
        },
        redo(): boolean {
          if (index + 1 >= snapshots.length) return false;
          restore(snapshots[++index]);
          return true;
        },
      };
    },
 
    onActivated: (store) => {
      snapshots.push(store.get());
      store.subscribe((get) => {
        if (isRestoring) return;
        snapshots.length = index + 1;
        snapshots.push(get());
        index = snapshots.length - 1;
      });
    },
  };
}

WARNING

Kin Store plugins have full access to the store from onActivated, onDestroy, and methods — but patching the store object itself is discouraged. Declare capabilities through methods and reducers instead; the plugin system is designed around those.

vs Zustand

Kin Store separates state from behavior by construction, and infers types without needing an annotation to remember. Zustand keeps state and actions in one object, so the type alone can't say what's data and what's behavior, and infers as any/unknown if you omit the explicit type annotation on create<State>() (or the innermost plugin call).

Kin Store's plugins read top-to-bottom: each .use() call adds one capability without touching the ones before it. Zustand's middleware nests instead, read right-to-left with the outer layer wrapping the inner one, so adding persist and devtools means three levels of nesting. Each middleware can also alter the store's own API shape: immer changes setState's updater from (state: TState) => TState | Partial<TState> to (state: WritableNonArrayDraft<TState>) => void, so what setState accepts depends on composition order.

import { history, immer, persist } from "@kintools/store-plugins";
import { useSelector, withPlugins } from "@kintools/store-react";
 
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
 
// Read top-to-bottom — each .use() adds one plugin, not one nesting level.
const todoStore = withPlugins({ todos: [], status: "idle" } as TodoState)
  .use("persist", persist({ key: "todos" }))
  .use("history", history())
  .use(
    immer({
      methods: (immerStore) => ({
        addTodo(text: string): void {
          immerStore.set((draft) => {
            draft.todos.push(text);
          });
        },
 
        async fetchTodos(): Promise<void> {
          immerStore.set((draft) => {
            draft.status = "loading";
          });
          try {
            const resp = await fetch("/api/todos");
            const todos = (await resp.json()) as Todo[];
            immerStore.set((draft) => {
              draft.todos = todos;
              draft.status = "idle";
            });
          } catch {
            immerStore.set((draft) => {
              draft.status = "failed";
            });
          }
        },
      }),
    }),
  );
 
// Plugins can be namespaced — no conflicts, no configuration buried in wrappers.
await todoStore.persist.hydrate();
todoStore.history.undo();
 
// In React — 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/Plugin modelDeclarative object — declares reducers, methods, lifecycle hooksImperative wrapper — each layer may alter set, get, or the store shape
Adding persist.use('persist', persist(...))Wrap entire store in persist(...)
Adding immer.use('immer', immer())Wrap again in immer(...)
Adding devtools.use('devtools', devtools(...))Wrap again in devtools(...)
Reading pipeline 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, so writing one means listing what it contributes (reducers, middleware, methods, lifecycle hooks) with no runtime patching involved. Every Zustand middleware instead implements the StateCreator protocol directly: receive (fn, set, get, api), patch api to add new behavior, then call fn(set, get, api) and return its result. An undo/redo middleware built this way needs the full ceremony: a declare module augmentation for the types, the history namespace added by mutating api as any, and the whole thing cast via as unknown as History because the type system can't follow the runtime mutation, the same pattern every official Zustand middleware uses.

import { getPluginDispatch } from "@kintools/store-core";
import type {
  InferActions,
  NestedMethods,
  NestedReducers,
  StorePlugin,
} from "@kintools/store-core";
 
type HistoryReducers<TState> = {
  _restore: (state: TState, saved: TState) => TState;
};
 
type HistoryMethods = {
  canUndo(): boolean;
  canRedo(): boolean;
  undo(): boolean;
  redo(): boolean;
};
 
// TState flows through every type position — no any needed.
export function history<
  TState,
  TStoreReducers extends NestedReducers<TState>,
  TStoreMethods extends NestedMethods,
  TNamespace extends string | undefined,
>(): StorePlugin<
  TState,
  TStoreReducers,
  TStoreMethods,
  TNamespace,
  HistoryReducers<TState>,
  HistoryMethods
> {
  const snapshots: TState[] = [];
  let index = 0;
  let isRestoring = false;
 
  return {
    reducers: {
      // A declared reducer — visible in devtools.
      _restore: (_state, saved) => saved,
    },
 
    methods: (store, { namespace }) => {
      const dispatch = getPluginDispatch(store, namespace);
 
      function restore(state: TState): void {
        isRestoring = true;
        dispatch._restore(state);
        isRestoring = false;
      }
 
      return {
        canUndo: () => index > 0,
        canRedo: () => index + 1 < snapshots.length,
        undo(): boolean {
          if (index <= 0) return false;
          restore(snapshots[--index]);
          return true;
        },
        redo(): boolean {
          if (index + 1 >= snapshots.length) return false;
          restore(snapshots[++index]);
          return true;
        },
      };
    },
 
    onActivated: (store) => {
      snapshots.push(store.get());
      store.subscribe((get) => {
        if (isRestoring) return;
        snapshots.length = index + 1;
        snapshots.push(get());
        index = snapshots.length - 1;
      });
    },
  };
}

What's different:

Kin Store pluginZustand middleware
Type extensionStorePlugin genericsdeclare module augmentation + as unknown as History
Expose methodsmethods on a plain objectMutate api as any
Restore state_restore reducer — full pipelineapi.setState(saved, true) — bypasses all middlewares
Name collisionThrows at registration timeSilent overwrite

WARNING

Kin Store plugins have full access to the store from onActivated, onDestroy, and methods — but patching the store object itself is discouraged. Declare capabilities through methods and reducers instead; the plugin system is designed around those.

vs Jotai

Jotai is atom-based — each piece of state is its own atom, and derived atoms compose them. It's a different model rather than a worse one, but it means thinking in atoms rather than in domains. App logic must also be wrapped in atoms — atom(null, (get, set, arg) => ...) — there is no plain function style.

Both reading (useAtomValue) and writing (useSetAtom) are hook-bound inside React. Outside React, jotai/vanilla or getDefaultStore() provides a { get, set, sub } interface — but it is a separate path, not how you write most Jotai code.

When a write atom throws, the stack trace surfaces at the useSetAtom call site in your component, not at the atom definition. A chain of atoms triggering other atoms can be 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 just be 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 can be accessed directly anywhere.
  // No hooks required.
 
  // ...
}

What's different:

Kin StoreJotai
State modelStores (value + subscribers)Atoms
App logicPlain functions / methodsWrapped in atoms
Read / write outside ReactYes — get(), set() and plain functions / methodsjotai/vanilla or getDefaultStore()
Reactive compositionderive((get) => ...)Derived atoms
Mental model"think in domains""think in atoms"

vs MobX

Kin Store's reactivity is explicit: state changes only through set or a dispatched reducer, and a component only re-renders because it called useStore/useSelector itself. MobX takes the opposite approach: makeAutoObservable silently instruments every property and method on a class into observables, computeds, and actions, so mutations just work with no subscription code to write. That implicitness costs in two places: async methods need runInAction to keep the reactive graph consistent, and every React component reading observable state needs observer(), and forgetting either one fails silently, stale data with no error, rather than throwing. At 15.6 KB gzipped, it's also one of the heaviest libraries in this comparison, behind only Redux/RTK.

import { useSelector, withPlugins } from "@kintools/store-react";
 
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
 
// Plain object — no class, no proxy, no instrumentation.
const todoStore = withPlugins<TodoState>({ todos: [], status: "idle" })
  .use({
    methods: (store) => ({
      addTodo(text: string): void {
        store.set((s) => ({
          ...s,
          todos: [...s.todos, { id: Date.now(), text, done: false }],
        }));
      },
      async fetchTodos(): Promise<void> {
        store.set((s) => ({ ...s, status: "loading" }));
        try {
          const resp = await fetch("/api/todos");
          const todos = (await resp.json()) as Todo[];
          // set is always safe after await.
          store.set({ todos, status: "idle" });
        } catch {
          store.set((s) => ({ ...s, 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 groceries")}>
      Add
    </button>
  );
}

What's different:

Kin StoreMobX
State mutationsset — no proxyMutable (proxy-intercepted)
Async updatesset after await — no wrapperMust wrap in runInAction
Call logic in ReactDirect — no hook neededDirect — no hook needed
Read state in ReactuseSelector only where neededobserver() on every component
Class requiredNo — plain objectYes (or observable({...}))
Reactive graphExplicit via deriveImplicit, auto-tracked
Silent stale-data bugsNoneTwo sources (runInAction, observer)