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.05 KB | 389 B | 17.57 KB | 3.98 KB | 15.62 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 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 "@kin-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();import {
configureStore,
createAsyncThunk,
createSlice,
} from "@reduxjs/toolkit";
import type { Middleware, PayloadAction } from "@reduxjs/toolkit";
type Todo = { id: number; text: string; done: boolean };
type TodoState = { todos: Todo[]; status: "idle" | "loading" | "failed" };
// Async action must be defined separately from the slice that handles it.
const fetchTodos = createAsyncThunk("todos/fetch", async () => {
const resp = await fetch("/api/todos");
return (await resp.json()) as Todo[];
});
const todosSlice = createSlice({
name: "todos",
initialState: { todos: [], status: "idle" } as TodoState,
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
state.todos.push({ id: Date.now(), text: action.payload, done: false });
},
},
// Async results are handled in a separate block from sync reducers.
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (s) => {
s.status = "loading";
})
.addCase(fetchTodos.fulfilled, (s, a) => {
s.todos = a.payload;
s.status = "idle";
})
.addCase(fetchTodos.rejected, (s) => {
s.status = "failed";
});
},
});
// Middleware is a curried function — three layers of arrow functions.
const logger: Middleware = (api) => (next) => (action) => {
console.log("dispatching", action);
return next(action);
};
const store = configureStore({
reducer: { todos: todosSlice.reducer },
middleware: (m) => m().concat(logger),
});
// TypeScript requires these to be exported manually.
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
// Actions must be accessed through the slice object, not the store.
store.dispatch(todosSlice.actions.addTodo("Buy groceries"));
store.dispatch(fetchTodos()); // Returns a thunk, not a plain action.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 |
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 "@kin-store/core";
import type {
InferActions,
NestedMethods,
NestedReducers,
StorePlugin,
} from "@kin-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;
});
},
};
}import { configureStore } from "@reduxjs/toolkit";
import type { StoreEnhancer } from "@reduxjs/toolkit";
type HistoryExt = {
history: {
canUndo(): boolean;
canRedo(): boolean;
undo(): boolean;
redo(): boolean;
};
};
// StoreEnhancer<Ext> does not thread state — TState must be manually inferred
// from the reducer. Action types still require casts to satisfy Redux's Action.
function makeHistory(): StoreEnhancer<HistoryExt> {
return (createStoreApi) => (reducer: any, preloadedState: any) => {
type TState = ReturnType<typeof reducer>;
type RestoreAction = { type: "@@HISTORY/RESTORE"; payload: TState };
const snapshots: TState[] = [];
let index = 0;
let isRestoring = false;
// Wrap the reducer to intercept a private RESTORE action.
const wrapped: typeof reducer = (state, action) =>
// Type casts required for type safety.
(action as unknown as RestoreAction).type === "@@HISTORY/RESTORE"
? (action as unknown as RestoreAction).payload
: reducer(state, action);
const store = createStoreApi(wrapped, preloadedState);
snapshots.push(store.getState());
store.subscribe(() => {
if (isRestoring) return;
snapshots.length = index + 1;
snapshots.push(store.getState());
index = snapshots.length - 1;
});
function restore(saved: TState): void {
isRestoring = true;
store.dispatch(
// Type cast required for type safety.
{ type: "@@HISTORY/RESTORE", payload: saved } as RestoreAction as never,
);
isRestoring = false;
}
return {
...store,
history: {
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;
},
},
};
};
}
const store = configureStore({
reducer: rootReducer,
enhancers: (getDefaultEnhancers) =>
getDefaultEnhancers().concat(makeHistory()),
});
store.history.undo(); // ✓ — snapshots are TState[], but required manual inference and castsWARNING
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 "@kin-store/plugins";
import { withPlugins, useSelector } from "@kin-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>;
}import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
type Todo = { id: number; text: string; done: boolean };
// State and actions must share one type — no structural separation.
type TodoStore = {
todos: Todo[];
status: "idle" | "loading" | "failed";
addTodo: (text: string) => void;
fetchTodos: () => Promise<void>;
};
// Read inside-out: immer → persist → devtools.
// The order matters and affects what `set` does inside each wrapper.
const useStore = create(
devtools(
persist(
// Explicit type annotation required.
immer<TodoStore>((set) => ({
todos: [],
status: "idle" as const,
addTodo: (text: string) =>
set((draft) => {
draft.todos.push({ id: Date.now(), text, done: false });
}),
fetchTodos: async () => {
set((draft) => {
draft.status = "loading";
});
try {
const resp = await fetch("/api/todos");
const todos = (await resp.json()) as Todo[];
set((draft) => {
draft.todos = todos;
draft.status = "idle";
});
} catch {
set((draft) => {
draft.status = "failed";
});
}
},
})),
{ name: "todos-storage" }, // persist config
),
{ name: "TodoStore" }, // devtools config
),
);
// In React — subscribing to addTodo registers a watcher that fires on every
// state change, even though addTodo is a stable ref that never changes.
function TodoApp() {
const todos = useStore((s) => s.todos);
const addTodo = useStore((s) => s.addTodo); // unnecessary subscription.
}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 "@kin-store/core";
import type {
InferActions,
NestedMethods,
NestedReducers,
StorePlugin,
} from "@kin-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;
});
},
};
}import { StateCreator, StoreMutatorIdentifier } from "zustand";
type Write<T, U> = Omit<T, keyof U> & U;
type HistoryApi = {
history: {
canUndo(): boolean;
canRedo(): boolean;
undo(): boolean;
redo(): boolean;
};
};
// Module augmentation required to extend the store's TypeScript type.
declare module "zustand/vanilla" {
interface StoreMutators<S, A> {
"custom/history": Write<S, HistoryApi>;
}
}
type History = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
fn: StateCreator<T, [...Mps, ["custom/history", never]], Mcs>,
) => StateCreator<T, Mps, [["custom/history", never], ...Mcs]>;
type HistoryImpl = <T>(
fn: StateCreator<T, [], []>,
) => StateCreator<T, [], []>;
// A higher-order function that wraps the original state creator and alters the
// store API.
const historyImpl: HistoryImpl = (fn) => (set, get, api) => {
// Manual type inference required because the type system can't follow the
// runtime mutation.
type TState = ReturnType<typeof get>;
const snapshots: TState[] = [];
let index = 0;
let isRestoring = false;
function restore(state: TState): void {
isRestoring = true;
api.setState(state, true);
isRestoring = false;
}
// Add history namespace by mutating api directly — silent override.
(api as typeof api & HistoryApi).history = {
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;
},
};
const state = fn(set, get, api);
snapshots.push(api.getState());
api.subscribe((current) => {
if (isRestoring) return;
snapshots.length = index + 1;
snapshots.push(current);
index = snapshots.length - 1;
});
return state;
};
// Type system can't follow the runtime mutation — double cast required.
export const history = historyImpl as unknown as History;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 "@kin-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.
// ...
}import { atom, useAtomValue, useSetAtom } from "jotai";
type Todo = { id: number; text: string; done: boolean };
// Each field is its own atom.
const todosAtom = atom<Todo[]>([]);
const statusAtom = atom<"idle" | "loading" | "failed">("idle");
// App logic must be wrapped in an atom.
const addTodoAtom = atom(null, (get, set, text: string) => {
set(todosAtom, (prev) => [...prev, { id: Date.now(), text, done: false }]);
});
const fetchTodosAtom = atom(null, async (get, set) => {
set(statusAtom, "loading");
try {
const todos = (await fetch("/api/todos").then((r) => r.json())) as Todo[];
set(todosAtom, todos);
set(statusAtom, "idle");
} catch {
set(statusAtom, "failed");
}
});
function TodoApp() {
const todos = useAtomValue(todosAtom);
const status = useAtomValue(statusAtom);
// Hooks required to access logic.
const addTodo = useSetAtom(addTodoAtom);
const fetchTodos = useSetAtom(fetchTodosAtom);
// ...
}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.62 KB gzipped, it's also one of the heaviest libraries in this comparison, behind only Redux/RTK.
import { withPlugins, useSelector } from "@kin-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>
);
}import { makeAutoObservable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
type Todo = { id: number; text: string; done: boolean };
class TodoStore {
todos: Todo[] = [];
status: "idle" | "loading" | "failed" = "idle";
constructor() {
// Instruments every field and method — no explicit list of what is reactive.
makeAutoObservable(this);
}
addTodo(text: string) {
this.todos.push({ id: Date.now(), text, done: false });
}
async fetchTodos() {
this.status = "loading";
try {
const resp = await fetch("/api/todos");
const todos = (await resp.json()) as Todo[];
// Mutations after an await must be wrapped in runInAction.
// Forgetting this causes silent stale-data bugs — no error, wrong UI.
runInAction(() => {
this.todos = todos;
this.status = "idle";
});
} catch {
runInAction(() => {
this.status = "failed";
});
}
}
}
export const todoStore = new TodoStore();
// Every component that reads observable state must be wrapped in observer().
// Forgetting observer() also causes silent stale-data bugs — no error thrown.
const TodoApp = observer(() => {
const { todos, status } = todoStore;
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) |