@jasperoosthoek/zustand-crud-registry
v0.3.3
Published
Flexible library to interact with a CRUD backend using Zustand and Axios
Readme
Zustand CRUD Registry
Effortless CRUD operations for React applications with automatic state management, loading states, and type safety.
Transform your React frontend into a powerful data management interface with just a few lines of code. This library combines Zustand state management with Axios HTTP client to provide reactive, type-safe CRUD operations that automatically sync your UI with your backend.
Key Features
- Automatic State Sync - Backend changes instantly reflect in your React components
- Type-Safe Operations - Full TypeScript support with intelligent autocompletion
- Built-in Loading States - No more manual loading/error state management
- Zero Boilerplate - Set up complete CRUD operations in minutes
- Flexible Configuration - Custom actions, routes, and data transformation
- Ordered Storage - Map-based storage preserves backend insertion order
- React Hooks - Clean, composable API for your components
Quick Start
Installation
npm install @jasperoosthoek/zustand-crud-registry zustand axiosBasic Setup
// stores/registry.ts
import { createStoreRegistry } from "@jasperoosthoek/zustand-crud-registry";
import axios from "axios";
// Define your data types
export type User = {
id: number;
name: string;
email: string;
};
export type Post = {
id: number;
title: string;
content: string;
userId: number;
};
// Create the store registry (only once in your app)
export const getOrCreateStore = createStoreRegistry<{
users: User;
posts: Post;
}>();
// Set up your HTTP client
const api = axios.create({ baseURL: '/api' });
// Create your stores
export const usersStore = getOrCreateStore('users', {
axios: api,
route: '/users',
actions: {
getList: true,
create: true,
update: true,
delete: true,
},
includeList: true, // Include list in useCrud return
});
export const postsStore = getOrCreateStore('posts', {
axios: api,
route: '/posts',
actions: {
getList: true,
create: true,
update: true,
delete: true,
},
includeList: true,
});Using in Components
// components/UsersList.tsx
import React, { useEffect } from 'react';
import { useCrud } from "@jasperoosthoek/zustand-crud-registry";
import { usersStore } from '../stores/registry';
export const UsersList = () => {
const users = useCrud(usersStore);
useEffect(() => {
if (!users.list) {
users.getList();
}
}, []);
const handleCreateUser = () => {
users.create({
name: 'New User',
email: '[email protected]'
});
};
const handleDeleteUser = (user: User) => {
users.delete(user);
};
if (users.getList.isLoading) {
return <div>Loading users...</div>;
}
if (users.getList.error) {
return <div>Error: {users.getList.error.message}</div>;
}
return (
<div>
<button onClick={handleCreateUser} disabled={users.create.isLoading}>
{users.create.isLoading ? 'Creating...' : 'Add User'}
</button>
<div>
{users.list?.map(user => (
<div key={user.id} className="user-item">
<span>{user.name} - {user.email}</span>
<button
onClick={() => handleDeleteUser(user)}
disabled={users.delete.isLoading && users.delete.id === user.id}
>
{users.delete.isLoading && users.delete.id === user.id ? 'Deleting...' : 'Delete'}
</button>
</div>
))}
</div>
</div>
);
};Complete Examples
Advanced Store Configuration
// stores/advanced.ts
import { createStoreRegistry } from "@jasperoosthoek/zustand-crud-registry";
import axios from "axios";
export type Task = {
id: number;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
order: number;
};
export const getOrCreateStore = createStoreRegistry<{
tasks: Task;
}>();
const api = axios.create({
baseURL: '/api',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
export const tasksStore = getOrCreateStore('tasks', {
axios: api,
route: '/tasks',
actions: {
getList: {
method: 'post', // Custom HTTP method
route: '/tasks/search', // Custom route
},
create: true,
update: true,
delete: true,
},
// Custom actions for additional endpoints
customActions: {
reorder: {
route: ({ id }: Task) => `/tasks/${id}/reorder`,
method: 'patch',
},
toggleComplete: {
route: ({ id }: Task) => `/tasks/${id}/toggle`,
method: 'post',
},
},
includeList: true,
// Component state (separate from server data)
state: {
selectedTaskId: null as number | null,
filterBy: 'all' as 'all' | 'completed' | 'pending',
},
// Error handling
onError: (error) => {
console.error('Task operation failed:', error);
// Could integrate with toast notifications, etc.
},
});Component with Custom Actions
// components/TaskManager.tsx
import React, { useEffect } from 'react';
import { useCrud } from "@jasperoosthoek/zustand-crud-registry";
import { tasksStore } from '../stores/advanced';
export const TaskManager = () => {
const tasks = useCrud(tasksStore);
useEffect(() => {
tasks.getList();
}, []);
const handleToggleComplete = (task: Task) => {
// Custom action usage
tasks.toggleComplete(task);
};
const handleFilterChange = (filter: 'all' | 'completed' | 'pending') => {
// Update component state
tasks.patchState({ filterBy: filter });
};
const filteredTasks = tasks.list?.filter(task => {
if (tasks.state.filterBy === 'completed') return task.completed;
if (tasks.state.filterBy === 'pending') return !task.completed;
return true;
});
return (
<div>
<div>
<button onClick={() => handleFilterChange('all')}>All</button>
<button onClick={() => handleFilterChange('completed')}>Completed</button>
<button onClick={() => handleFilterChange('pending')}>Pending</button>
</div>
<div>
{filteredTasks?.map(task => (
<div key={task.id} className="task-item">
<span>{task.title}</span>
<button
onClick={() => handleToggleComplete(task)}
disabled={tasks.toggleComplete.isLoading && tasks.toggleComplete.id === task.id}
>
{task.completed ? 'Mark Pending' : 'Mark Complete'}
</button>
</div>
))}
</div>
</div>
);
};Custom Response Handling
// hooks/useTasksWithReorder.ts
import { useCrud } from "@jasperoosthoek/zustand-crud-registry";
import { tasksStore } from '../stores/advanced';
export const useTasksWithReorder = () => {
const tasks = useCrud(tasksStore);
// Handle reorder response — update only the order fields without refetching
tasks.reorder.onResponse = (reorderedTasks: Partial<Task>[]) => {
tasksStore.patchList(reorderedTasks);
};
return tasks;
};API Reference
createStoreRegistry<Models>()
Creates a store registry function. Call this only once in your application.
Parameters:
Models: TypeScript type defining your entity models
Returns: getOrCreateStore function
getOrCreateStore(key, config)
Creates or retrieves a store for a specific entity type.
Parameters:
key: String key matching your Models typeconfig: Store configuration object
Config Options:
{
axios: AxiosInstance; // HTTP client instance
route: string | RouteFunction; // Base API route
actions?: { // Enable/configure CRUD operations
getList?: boolean | ActionConfig;
get?: boolean | ActionConfig;
create?: boolean | ActionConfig;
update?: boolean | ActionConfig;
delete?: boolean | ActionConfig;
};
customActions?: { // Custom API endpoints
[name: string]: CustomActionConfig;
};
state?: object; // Component state
onError?: (error: any) => void; // Error handler
detailKey?: string; // Field used in detail routes (default: 'id')
id?: string; // Field used to key internal Map (default: detailKey)
includeList?: boolean; // Include list array in useCrud return
includeRecord?: boolean; // Include record object in useCrud return
pagination?: true | { // Enable pagination
limit?: number;
offset?: number;
prepare?: (responseData: any) => Partial<Pagination>; // map response → { count, offset, limit }
prepareParams?: (pagination: Pagination) => Record<string, any>; // map pagination → query params
extractList?: (responseData: any) => any[]; // pick list from response (default: response.data)
};
select?: 'single' | 'multiple'; // Enable selection
}useCrud(store)
Main hook for interacting with your store.
Returns:
{
// Data views (opt-in via config)
list?: T[] | null; // Array of entities (if includeList: true)
record?: {[id: string]: T} | null; // Keyed object of entities (if includeRecord: true)
// CRUD operations (if enabled)
getList: AsyncFunction;
get: AsyncFunction;
create: AsyncFunction;
update: AsyncFunction;
delete: AsyncFunction;
// Custom actions
[customAction]: AsyncFunction;
// State management (if configured)
state: StateObject;
patchState: (partial: Partial<StateObject>) => void;
// Pagination (if configured)
pagination: { count: number; offset: number; limit: number };
setPagination: (partial: Partial<Pagination>) => void;
// Selection (if configured)
selected: T | null; // if select: 'single'
selected: T[]; // if select: 'multiple'
select: (instanceOrId: T | string | number | null) => void;
toggle: (instanceOrId: T | string | number) => void;
clear: () => void;
// Loading states for each operation
// Each operation has: isLoading, error, response, id, sequence
}Granular Hooks
For more control, use the standalone hooks instead of useCrud:
useList(store)- ReturnsT[] | null(ordered array)useRecord(store)- Returns{ [key: string]: T } | null(keyed object)useGet(store, id?)- Returns[instance, get]tuple; auto-fetches on mount whenidis provideduseGetList(store)- Returns[list, getList]tuple; auto-fetches on mountuseSelect(store)- Returns selection state withselectedId/selectedIdsusePagination(store)- Returns[pagination, setPagination]tupleuseCrudState(store)- Returns{ state, patchState }for custom component state
Store State Functions
The store exposes mutation functions directly on the store object (also available via store.getState()):
List Operations:
store.setList(items: T[] | null)- Replaces all data (or clears withnull)store.patchList(items: Partial<T>[])- Updates existing items only (ignores new items)store.updateList(items: T[])- Upserts items (updates existing, inserts new)
Single Item Operations:
store.setInstance(item: T)- Adds or replaces a single itemstore.updateInstance(item: T)- Merges updates into an existing itemstore.deleteInstance(item: T)- Removes an item from the store
State Management:
store.patchState(partial: Partial<State>)- Updates custom component state (whenstateis configured)store.setPagination(partial: Partial<Pagination>)- Updates pagination (whenpaginationis configured)store.setSelectedIds(ids: string[])- Updates selection (whenselectis configured)store.getState().setLoadingState(key: string, value: Partial<LoadingState>)- Manually update loading state
Advanced Usage
Route Functions
// Dynamic routes based on data
const store = getOrCreateStore('posts', {
axios: api,
route: '/posts',
actions: {
getList: {
route: ({ userId }: { userId: number }) => `/users/${userId}/posts`
},
update: {
method: 'put', // Use custom http method instead of default patch
route: (post: Post) => `/posts/${post.id}/update`
}
}
});Data Transformation
// Transform data before sending to API
const store = getOrCreateStore('users', {
axios: api,
route: '/users',
actions: {
create: {
prepare: (userData) => ({
...userData,
createdAt: new Date().toISOString()
})
}
}
});Error Handling
const store = getOrCreateStore('users', {
axios: api,
route: '/users',
onError: (error) => {
if (error.response?.status === 401) {
// Handle authentication error
redirectToLogin();
} else {
// Show error notification
showErrorToast(error.message);
}
}
});🛠️ Best Practices
1. Store Organization
// stores/index.ts - Export all stores from one place
export * from './users';
export * from './posts';
export * from './registry';2. Default Configuration
// stores/config.ts - Reuse common configuration
export const defaultConfig = {
axios: apiClient,
actions: {
getList: true,
create: true,
update: true,
delete: true,
},
onError: handleApiError,
};
// stores/users.ts
export const usersStore = getOrCreateStore('users', {
...defaultConfig,
route: '/users',
});3. Custom Hooks
// hooks/useUsers.ts - Wrap logic in custom hooks
import { useList } from "@jasperoosthoek/zustand-crud-registry";
export const useUsers = () => {
const users = useCrud(usersStore);
const list = useList(usersStore);
useEffect(() => {
if (!list) {
users.getList();
}
}, []);
return { ...users, list };
};4. Type Safety
// types/api.ts - Define your API types
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
export interface CreateUserRequest {
name: string;
email: string;
}Troubleshooting
Common Issues
Q: TypeScript errors about store types
// ❌ Don't do this - creates multiple registries
const store1 = createStoreRegistry<{...}>()('users', config);
const store2 = createStoreRegistry<{...}>()('posts', config);
// ✅ Do this - single registry
const getOrCreateStore = createStoreRegistry<{...}>();
const store1 = getOrCreateStore('users', config);
const store2 = getOrCreateStore('posts', config);Q: Actions not working
// ❌ Make sure actions are enabled
const store = getOrCreateStore('users', {
axios: api,
route: '/users',
// actions: { ... } // Missing actions config
});
// ✅ Enable the actions you need
const store = getOrCreateStore('users', {
axios: api,
route: '/users',
actions: {
getList: true,
create: true,
// ... other actions
}
});Q: Loading states not updating
// ❌ Don't destructure loading states
const { getList, getList: { isLoading } } = useCrud(store);
// ✅ Access loading states directly
const users = useCrud(store);
const isLoading = users.getList.isLoading;Examples & Demos
- Live Demo - Complete dashboard application
- Dashboard Demo Repository - Example dashboard project with examples
Contributing
We welcome contributions! Please see our Contributing Guide for details.
Development Setup
# Clone the repository
git clone https://github.com/jasperoosthoek/zustand-crud-registry.git
# Install dependencies
npm install
# Build the project
npm run build
# Run tests
npm testLicense
MIT © jasperoosthoek
Acknowledgments
- Built with Zustand for state management
- HTTP client powered by Axios
- Inspired by the need for simpler CRUD operations in React applications
Made with ❤️ by @jasperoosthoek
