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
createStore231 Bget, set, subscribe. Nothing else.
withPlugins1.0 KBAdd methods, reducers, and middleware, one .use() at a time.
derive438 BCompose 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 structure and traceability, without the ceremony
Skip it when the simple thing is enough
- You need server-owned state: that's TanStack Query/SWR's job
- 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 Store | Zustand | Redux / RTK | Jotai | MobX | |
|---|---|---|---|---|---|
| Bundle size | 2.0 KB | 389 B | 17.5 KB | 4.0 KB | 15.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
01Declare
import { createStore } from "@kintools/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",
});02Read, write, subscribe
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"03Compose
derive automatically tracks dependencies without requiring a complex
reactive graph runtime, thanks to the explicit get(store) calls.
import { derive } from "@kintools/store-core";
const itemCount = derive((get) => get(todos).items.length);
console.log(itemCount.get()); // 104When the store earns it, add structure
Each use() registers a plugin (namespaced or top-level).
Plugins are plain objects declaring methods, reducers, middleware, lifecycle hooks.
Nothing wraps or patches the store.
import { withPlugins } from "@kintools/store-core";
import { devtools, persist } from "@kintools/store-plugins";
const store = withPlugins(todos)
.use("persist", persist({ key: "todos" }))
.use("devtools", devtools())
.use({
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.05Need traceability? Add reducers and replace set by
dispatch for those changes
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
import { useSelector, useStore } from "@kintools/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>
);
}