Comparison
The same todo store in each library, trimmed to what each section compares.
Feature matrix
| Kin Store | Zustand | Redux / RTK | Jotai | MobX | |
|---|---|---|---|---|---|
| Bundle size | 1.6 KB | 389 B | 17.5 KB | 4.0 KB | 15.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 Store | Zustand | |
|---|---|---|
| Extension model | declarative object: methods, lifecycle hooks | imperative wrapper: may alter set, get, or the store shape |
| Adding a plugin | .use(...) | wrap the whole store again |
| Reading order | top-to-bottom | inside-out |
| State vs actions | structurally separate | same object |
| Call logic in React | call directly, no hook | hook 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 plugin | Zustand middleware | |
|---|---|---|
| Type extension | StorePlugin generics | declare module augmentation + as unknown as History |
| Expose methods | named methods on a plain object | mutate api as any |
| Restore state | this.set(), runs through every listener | api.setState(saved, true), bypasses all middlewares |
| Name collision | throws at registration time | silent 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 Store | Redux / RTK | |
|---|---|---|
| Async actions | methods that call set/merge | createAsyncThunk + extraReducers |
| Type exports | fully inferred, zero exports | RootState, AppDispatch manual exports |
| Access pattern | store.addTodo(...) | slice.actions.addTodo(...) |
| Call logic in React | call directly, no hook | useDispatch() 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 Store | Jotai | |
|---|---|---|
| State model | stores (value + subscribers) | atoms |
| App logic | plain functions / methods | wrapped in atoms |
| Read / write outside React | yes: get(), set() and plain functions | jotai/vanilla or getDefaultStore() |
| Reactive composition | derive((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 Store | MobX | |
|---|---|---|
| State mutations | set/merge, no proxy | mutable (proxy-intercepted) |
| Async updates | set/merge after await, no wrapper | must wrap in runInAction |
| Read state in React | useStore, useSelector only where needed | observer() on every component |
| Class required | no, plain object | yes (or observable({...})) |
| Reactive graph | explicit via derive | implicit, auto-tracked |