Comparison
The same todo store — { todos, status } with addTodo and fetchTodos —
implemented in each library. Full, working setup in every example.
Feature matrix
| Kin Store | Zustand | Redux / RTK | Jotai | MobX | |
|---|---|---|---|---|---|
| Bundle size | 2.0 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.
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 Store | Redux / RTK | |
|---|---|---|
| Async actions | Method that calls reducers | createAsyncThunk + extraReducers |
| Middleware | (ctx, next) => ... | (api) => (next) => (action) => ... |
| Type exports | Fully inferred — zero exports | RootState, AppDispatch manual exports |
| Access pattern | store.dispatch.addTodo(...) | slice.actions.addTodo(...) |
| Call logic in React | Call directly — no hook | useDispatch() 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 Store | Zustand | |
|---|---|---|
| Extension/Plugin model | Declarative object — declares reducers, methods, lifecycle hooks | Imperative 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 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, 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 plugin | Zustand middleware | |
|---|---|---|
| Type extension | StorePlugin generics | declare module augmentation + as unknown as History |
| Expose methods | methods on a plain object | Mutate api as any |
| Restore state | _restore reducer — full pipeline | api.setState(saved, true) — bypasses all middlewares |
| Name collision | Throws at registration time | Silent 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 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 / methods | jotai/vanilla or getDefaultStore() |
| Reactive composition | derive((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 Store | MobX | |
|---|---|---|
| State mutations | set — no proxy | Mutable (proxy-intercepted) |
| Async updates | set after await — no wrapper | Must wrap in runInAction |
| Call logic in React | Direct — no hook needed | Direct — no hook needed |
| Read state in React | useSelector only where needed | observer() on every component |
| Class required | No — plain object | Yes (or observable({...})) |
| Reactive graph | Explicit via derive | Implicit, auto-tracked |
| Silent stale-data bugs | None | Two sources (runInAction, observer) |