Skip to content

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.05 KB389 B17.57 KB3.98 KB15.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.

ts
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();
ts
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 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

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.

ts
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;
      });
    },
  };
}
ts
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 casts

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.

ts
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>;
}
ts
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 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.

ts
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;
      });
    },
  };
}
ts
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 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.

ts
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.
  
  // ...
}
ts
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 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.62 KB gzipped, it's also one of the heaviest libraries in this comparison, behind only Redux/RTK.

ts
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>
  );
}
ts
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 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)

Released under the MIT License