A reactive state library for TypeScript.
Framework-agnostic, zero dependencies, 100% type-safe.
Why it exists
I wanted the smallest set of ideas a state library actually needs, and nothing past that. Three primitives came out of it: small enough to hold in your head, honest enough that nothing happens unless you triggered it.
What it does differently
createStore231 BGet, set, subscribe. Nothing else.
withPlugins1.01 KBAdd structure one .use() at a time. Reducers, methods, middleware, read top to bottom, run top to bottom.
derive438 BCompose stores into new ones. It tracks which stores you read, not a graph of who depends on whom.
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.
No proxies, no auto-tracked reactive graph, no immer unless you add it. State only changes where you called set or dispatch.
Each plugin declares what it adds. Stack ten of them and the chain still reads top-to-bottom, nothing nested to unwind.
derive tracks which stores you read automatically. No selector library, no dependency array to keep in sync by hand.
See it for yourself
01 Declare
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
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
import { derive } from "@kin-store/core";
const itemCount = derive((get) => get(todos).items.length);
console.log(itemCount.get()); // 104 Add structure, only when you want it
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
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 "@kin-store/react";
function Counter(): JSX.Element {
const value = useStore(count); // Re-renders on every change.
return <button onClick={() => count.set((n) => n + 1)}>{value}</button>;
}
function TodoList(): JSX.Element {
const items = useSelector(store, (s) => s.items); // Re-renders only when items changes.
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>
);
}