Zustand CRUD in one line
Zenty is a small TypeScript library for Zustand. You define the type, Zenty gives you the store: add, update, delete, find, bulk operations, plus loading and error state. Works with React 18, React 19 and Next.js.
1import { createEntitiesStore } from 'zenty'
2
3type Product = { id: string; name: string; price?: number }
4
5export const useProducts = createEntitiesStore<Product>()
6
7// anywhere in your app
8useProducts.getState().add({ id: 'p1', name: 'Desk lamp', price: 39 })- 1 line per store
- Fully typed
- Loading & error included
error
error
error
error
{
"entities": [],
"loaded": false,
"loading": false,
"error": null
}More actions
No products yet. Add some products to see them here!
npm
1npm install zenty zustandyarn
1yarn add zenty zustandpnpm
1pnpm add zenty zustandSame store, written twice
On the left is a hand-written Zustand store for a product list. It does exactly what Zenty does. Not more, not less. On the right is the Zenty version.
import { create } from 'zustand'
type Product = {
id: string
name: string
price?: number
category?: string
}
type ProductState = {
entities: Product[]
loaded: boolean
loading: boolean
error: string | null
}
type ProductActions = {
add: (item: Product) => void
addMany: (items: Product[]) => void
update: (id: string, patch: Partial<Product>) => void
updateMany: (patches: (Pick<Product, 'id'> & Partial<Product>)[]) => void
delete: (id: string) => void
deleteMany: (ids: string[]) => void
find: (id: string) => Product | undefined
has: (id: string) => boolean
clear: () => void
replaceAll: (items: Product[]) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
}
export const useProductStore = create<ProductState & ProductActions>()((set, get) => ({
entities: [],
loaded: false,
loading: false,
error: null,
add: (item) =>
set((state) => {
if (state.entities.some((e) => e.id === item.id)) {
return { loaded: true, error: `Item with id=${item.id} already exists.` }
}
return { entities: [...state.entities, item], loaded: true, error: null }
}),
addMany: (items) =>
set((state) => ({
entities: [...state.entities, ...items],
loaded: true,
error: null,
})),
update: (id, patch) =>
set((state) => ({
entities: state.entities.map((e) => (e.id === id ? { ...e, ...patch } : e)),
error: null,
})),
updateMany: (patches) =>
set((state) => ({
entities: state.entities.map((e) => {
const p = patches.find((x) => x.id === e.id)
return p ? { ...e, ...p } : e
}),
error: null,
})),
delete: (id) =>
set((state) => ({
entities: state.entities.filter((e) => e.id !== id),
error: null,
})),
deleteMany: (ids) =>
set((state) => ({
entities: state.entities.filter((e) => !ids.includes(e.id)),
error: null,
})),
find: (id) => get().entities.find((e) => e.id === id),
has: (id) => get().entities.some((e) => e.id === id),
clear: () => set({ entities: [], loaded: false, error: null }),
replaceAll: (items) => set({ entities: items, loaded: true, error: null }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
}))import { createEntitiesStore } from 'zenty'
type Product = {
id: string
name: string
price?: number
category?: string
}
export const useProductStore = createEntitiesStore<Product>()What you get
For lists (createEntitiesStore)
add, addMany, update, updateMany, delete, deleteMany, find, has, replaceAll and clear. Plus entities, loaded, loading and error.
For single objects (createEntityStore)
set, update and clear. Turn on deepMerge and nested updates keep the fields you didn't touch.
Your own ID field
Not every API calls it id. Pass idKey: 'userId' and everything is typed against that key.
Override any action
Want new items sorted by name? Pass your own add. The rest stays default.
What Zenty doesn't do
It doesn't fetch, cache or refetch data. It has no selection state. And it doesn't plug into Zustand middleware like persist or devtools yet. If you need those, a hand-written Zustand store or TanStack Query is the better fit.
Zenty FAQ
Short answers. The docs have the details.
What is Zenty?
Zenty is an open-source TypeScript library that adds CRUD to Zustand. createEntitiesStore gives you a typed store for a list, createEntityStore one for a single object. Both come with loading, loaded and error. It's MIT-licensed and about 0.7 kB gzipped.
Is Zenty a replacement for Zustand?
No. Zenty calls Zustand's create() under the hood and hands you a normal Zustand hook. You still install Zustand, and your existing stores keep working next to the Zenty ones. So you can move one store at a time, or just use Zenty for new ones.
How do I install Zenty?
npm install zenty zustandZenty works with Zustand 4.4+ and 5, and React 18 or 19.
How do I manage a list?
Start with:
const useProducts = createEntitiesStore<Product>()If your type has an id field, that's it. If the ID is called something else, pass idKey, e.g. createEntitiesStore<User, 'userId'>({ idKey: 'userId' }). One thing worth knowing: add refuses duplicates and sets error. update and delete on an ID that doesn't exist do nothing and don't report it.
How do I manage a single object, like settings or the current user?
Use createEntityStore with deepMerge on:
const useSettings = createEntityStore<Settings>({ deepMerge: true })set replaces the object, update merges into it, clear resets it to null. With deepMerge: true, nested objects are merged instead of overwritten. In TypeScript strict mode a nested partial still needs a cast, because update is typed as Partial<T>. This is a known limitation.
How do I load data from an API?
Zenty doesn't fetch for you. You call your API and put the result into the store:
const { setLoading, setError, replaceAll } = useProducts.getState()
setLoading(true)
try {
const res = await fetch('/api/products')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
replaceAll(await res.json())
} catch {
setError('Could not load products')
} finally {
setLoading(false)
}replaceAll and setError don't reset loading, so the finally block is still yours to write.
Does Zenty work with Next.js?
Yes, in client components ('use client'). A Zenty store is a regular module-level Zustand store, so the usual Zustand advice for Next.js applies. Don't keep per-user data in it during server rendering.
Zenty or TanStack Query?
Different jobs. TanStack Query caches server data and handles refetching, stale data and invalidation. Zenty is a plain client store you control yourself. It fits when you want the data in Zustand anyway: local edits before saving, a list fed by a WebSocket or a Firestore listener, or an admin table you update by hand. Plenty of apps use both.
Zenty or Redux Toolkit?
If you're already on Zustand, adding Redux for CRUD is a lot of setup for little gain. Redux Toolkit is a full framework with slices, middleware, devtools and RTK Query. Zenty does one thing: the CRUD part of a Zustand store. If you need devtools or time-travel debugging today, RTK is ahead.
Zenty compared
For CRUD state in a React app.
Scroll horizontally to see all columns.
| Criteria | Zenty | Zustand, hand-written | Redux Toolkit | Context + useReducer |
|---|---|---|---|---|
| Code for a list store | 1 line | ~90 lines (see above) | slice + store setup | reducer + provider |
| Typed actions | inferred from your type | you write the types | yes | you write the types |
| Loading / error state | included | you write it | via thunks or RTK Query | you write it |
| Data fetching & caching | no | no | yes (RTK Query) | no |
| Devtools / middleware | not yet | yes | yes | no |
| Good fit for | CRUD lists and objects in Zustand apps | custom store logic | large apps, complex flows | small, local state |