tact is a tiny, persisted-by-default, schema-validated state library. 1.4kb gzipped, zero dependencies, perfect TypeScript inference, and an opinion about when to wipe stale state.
// 1. Define a store · plain TypeScript import { defineStore, z } from "tact" export const useCart = defineStore({ name: "cart", schema: z.object({ items: z.array(z.object({ id: z.string(), qty: z.number().int().positive(), })), coupon: z.string().nullable(), }), initial: () => ({ items: [], coupon: null }), // auto-forget when stale forget: { after: "30d", when: "signedOut" } })
// 2. Use it · React, full inference import { useCart } from "./store" export function Cart() { const { items, addItem, forget } = useCart() return ( <div> <h2>{items.length} item(s)</h2> <button onClick={() => addItem({ id: "abc", qty: 1 })}>Add</button> {/* opt-out: wipe locally + remotely */} <button onClick={forget}>Forget</button> </div> ) }
tact was written to replace the 14 utility hooks every team rewrites. Schema-first, persisted-by-default, never-grows-stale.
Smaller than your shadcn button. The 4 kb you save translates into ~14 ms of TTI on a mid-tier Android.
Pass a z.object(). We validate on hydrate, on write, and on every IPC message. Bad data fails loudly.
Stores hydrate from localStorage / AsyncStorage / cookies depending on platform. Opt out per-store with persist: false.
Auto-wipe stale state with forget: { after: "30d", when: "signedOut" }. The library most others forgot to ship.
Add sync: "supabase" or your own adapter. Multi-tab + multi-device with conflict-resolution baked in.
React, RN, Svelte, Vue, Solid, vanilla. One defineStore API. Stores are framework-portable: same source, swap the hook.
If it doesn't feel obviously smaller than what you have, throw it out. We won't be mad. (We will be a little sad.)