Skip to content

Kin Store

Start with a plain store. Add structure only when the app earns it.

A framework-agnostic reactive state library for TypeScript.

Why it exists

Most state libraries pick your architecture before you know if the app needs one: actions, reducers, selectors, a provider tree, decided on day one. Kin Store leaves that decision to you.

set and dispatch are equally first-class, not a beginner tier and an advanced one, so the mutation style a store uses is a choice your team makes, not one the library makes for you.

What it does differently

01createStore231 B

get, set, subscribe. Nothing else.

02withPlugins1.0 KB

Add methods, reducers, and middleware, one .use() at a time.

03derive438 B

Compose stores into new ones. It tracks what you read, not a graph you maintain.

Minimal by default

A store starts as get, set, subscribe, nothing else. Methods, reducers, middleware, and derived stores are things you add when you reach for them, not things you start with.

Explicit, always

No proxies, no auto-tracked reactive graph, no immer unless you add it. State only changes where you called set or dispatch.

Plugins don't wrap

Each plugin declares what it adds. Stack ten of them and the chain still reads top-to-bottom, nothing nested to unwind.

Derived state, no wiring

derive tracks which stores you read automatically. No selector library, no dependency array to keep in sync by hand.

Is Kin Store a fit?

Use it when state should start minimal.

  • State should start minimal, not architected upfront.
  • You want typed reducers, middleware, or devtools, only where it matters.
  • You want one store that works the same in React, another framework, or plain JS/TS.

Skip it when the simple thing is enough.

  • You need server-owned state: that's TanStack Query's job, not a client store's.
  • You need non-React bindings today; Vue, Svelte, and Solid aren't published yet.
  • Redux or Zustand already works fine for your team.

How it compares

Kin StoreZustandRedux / RTKJotaiMobX
Bundle size2.0 KB389 B17.5 KB4.0 KB15.6 KB
Zero dependencies
100% type-safe⚠️⚠️
Low boilerplate⚠️⚠️⚠️
Separate state and logic
Opt-in complexity⚠️
No hidden magic

✅ 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.

For full comparison, see the details →

See it for yourself

01 Declare

ts
import { createStore } from "@kin-store/core";

const count = createStore(0);

const theme = createStore<"light" | "dark">("light");

type TodoState = {
  items: string[];
  status: "idle" | "loading";
};
const todos = createStore<TodoState>({
  items: [],
  status: "idle",
});

02 Read, write, subscribe

ts
count.set((n) => n + 1);
theme.set("dark");
todos.set((s) => ({ ...s, items: [...s.items, "Buy milk"] }));

console.log(count.get()); // 1

const unsubscribe = count.subscribe((get, prev) => {
  console.log(prev, "->", get());
});
count.set((n) => n + 1); // logs "1 -> 2"
unsubscribe();

03 Compose

ts
import { derive } from "@kin-store/core";

const itemCount = derive((get) => get(todos).items.length);
console.log(itemCount.get()); // 1

04 When the store earns it, add structure

ts
import { withPlugins } from "@kin-store/core";
import { devtools, persist } from "@kin-store/plugins";

const store = withPlugins(todos)
  .use("persist", persist({ key: "todos" }))
  .use("devtools", devtools())
  .use({
    // A plugin is a plain object: methods/reducers/middleware, nothing
    // wraps or patches the store to add them.
    methods: (store) => ({
      addTodo(text: string): void {
        store.set((s) => ({ ...s, items: [...s.items, text] }));
      },
      async fetchTodos(): Promise<void> {
        store.set((s) => ({ ...s, status: "loading" }));
        const items = await api.fetchTodos();
        store.set({ items, status: "idle" });
      },
    }),
  });

await store.persist.hydrate(); // From the namespaced persist plugin.
store.addTodo("Buy milk"); // From the top-level inline plugin.

05 Need traceability? Add reducers and replace set by dispatch for those changes

ts
const store = withPlugins(todos)
  .use("persist", persist({ key: "todos" }))
  .use("devtools", devtools())
  .use({
    reducers: {
      addTodo: (s, text: string) => ({ ...s, items: [...s.items, text] }),
      fetchStart: (s) => ({ ...s, status: "loading" }),
      fetchDone: (_s, items: string[]) => ({ items, status: "idle" }),
    },
    methods: (store) => ({
      async fetchTodos(): Promise<void> {
        store.dispatch.fetchStart();
        const items = await api.fetchTodos();
        store.dispatch.fetchDone(items);
      },
    }),
  });

store.dispatch.addTodo("Buy milk"); // Full intellisense, logged in devtools.

set/dispatch are both first-class here: pick whichever fits this store or method, not a ladder from one to the other.

In React

tsx
import { useSelector, useStore } from "@kin-store/react";

function Counter(): JSX.Element {
   // Re-renders on every change. Works great for primitive stores.
  const value = useStore(count);
  
  return <button onClick={() => count.set((n) => n + 1)}>{value}</button>;
}

function TodoList(): JSX.Element {
   // Re-renders only when items changes.
  const items = useSelector(store, (s) => s.items);

  return (
    <ul>
      {items.map((item) => <li key={item}>{item}</li>)}

      {/* Direct method reference. No hook, no subscription. */}
      <button onClick={() => store.addTodo("Buy milk")}>Add</button>
    </ul>
  );
}

Released under the MIT License