@malamute/ai-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/bin/cli.js +5 -0
- package/configs/_shared/.claude/commands/fix-issue.md +38 -0
- package/configs/_shared/.claude/commands/generate-tests.md +49 -0
- package/configs/_shared/.claude/commands/review-pr.md +77 -0
- package/configs/_shared/.claude/rules/accessibility.md +270 -0
- package/configs/_shared/.claude/rules/performance.md +226 -0
- package/configs/_shared/.claude/rules/security.md +188 -0
- package/configs/_shared/.claude/skills/debug/SKILL.md +118 -0
- package/configs/_shared/.claude/skills/learning/SKILL.md +224 -0
- package/configs/_shared/.claude/skills/review/SKILL.md +86 -0
- package/configs/_shared/.claude/skills/spec/SKILL.md +112 -0
- package/configs/_shared/CLAUDE.md +174 -0
- package/configs/angular/.claude/rules/components.md +257 -0
- package/configs/angular/.claude/rules/state.md +250 -0
- package/configs/angular/.claude/rules/testing.md +422 -0
- package/configs/angular/.claude/settings.json +31 -0
- package/configs/angular/CLAUDE.md +251 -0
- package/configs/dotnet/.claude/rules/api.md +370 -0
- package/configs/dotnet/.claude/rules/architecture.md +199 -0
- package/configs/dotnet/.claude/rules/database/efcore.md +408 -0
- package/configs/dotnet/.claude/rules/testing.md +389 -0
- package/configs/dotnet/.claude/settings.json +9 -0
- package/configs/dotnet/CLAUDE.md +319 -0
- package/configs/nestjs/.claude/rules/auth.md +321 -0
- package/configs/nestjs/.claude/rules/database/prisma.md +305 -0
- package/configs/nestjs/.claude/rules/database/typeorm.md +379 -0
- package/configs/nestjs/.claude/rules/modules.md +215 -0
- package/configs/nestjs/.claude/rules/testing.md +315 -0
- package/configs/nestjs/.claude/rules/validation.md +279 -0
- package/configs/nestjs/.claude/settings.json +15 -0
- package/configs/nestjs/CLAUDE.md +263 -0
- package/configs/nextjs/.claude/rules/components.md +211 -0
- package/configs/nextjs/.claude/rules/state/redux-toolkit.md +429 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +299 -0
- package/configs/nextjs/.claude/rules/testing.md +315 -0
- package/configs/nextjs/.claude/settings.json +29 -0
- package/configs/nextjs/CLAUDE.md +376 -0
- package/configs/python/.claude/rules/database/sqlalchemy.md +355 -0
- package/configs/python/.claude/rules/fastapi.md +272 -0
- package/configs/python/.claude/rules/flask.md +332 -0
- package/configs/python/.claude/rules/testing.md +374 -0
- package/configs/python/.claude/settings.json +18 -0
- package/configs/python/CLAUDE.md +273 -0
- package/package.json +41 -0
- package/src/install.js +315 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*slice*.ts"
|
|
4
|
+
- "**/*store*.ts"
|
|
5
|
+
- "**/store/**/*.ts"
|
|
6
|
+
- "**/redux/**/*.ts"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Redux Toolkit State Management
|
|
10
|
+
|
|
11
|
+
Use Redux Toolkit (RTK) for complex client-side state management.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
- Large applications with complex state
|
|
16
|
+
- Team already familiar with Redux
|
|
17
|
+
- Need advanced DevTools (time-travel debugging)
|
|
18
|
+
- Using RTK Query for data fetching
|
|
19
|
+
- Complex async logic with middleware
|
|
20
|
+
|
|
21
|
+
## Store Structure
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
libs/
|
|
25
|
+
[domain]/
|
|
26
|
+
data-access/
|
|
27
|
+
store/
|
|
28
|
+
index.ts # Store configuration
|
|
29
|
+
hooks.ts # Typed hooks
|
|
30
|
+
slices/
|
|
31
|
+
user-slice.ts
|
|
32
|
+
cart-slice.ts
|
|
33
|
+
api/
|
|
34
|
+
user-api.ts # RTK Query
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Store Setup
|
|
38
|
+
|
|
39
|
+
### Configure Store
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// store/index.ts
|
|
43
|
+
import { configureStore } from '@reduxjs/toolkit';
|
|
44
|
+
import { userSlice } from '../slices/user-slice';
|
|
45
|
+
import { cartSlice } from '../slices/cart-slice';
|
|
46
|
+
import { apiSlice } from '../api/api-slice';
|
|
47
|
+
|
|
48
|
+
export const makeStore = () => {
|
|
49
|
+
return configureStore({
|
|
50
|
+
reducer: {
|
|
51
|
+
user: userSlice.reducer,
|
|
52
|
+
cart: cartSlice.reducer,
|
|
53
|
+
[apiSlice.reducerPath]: apiSlice.reducer,
|
|
54
|
+
},
|
|
55
|
+
middleware: (getDefaultMiddleware) =>
|
|
56
|
+
getDefaultMiddleware().concat(apiSlice.middleware),
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type AppStore = ReturnType<typeof makeStore>;
|
|
61
|
+
export type RootState = ReturnType<AppStore['getState']>;
|
|
62
|
+
export type AppDispatch = AppStore['dispatch'];
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Typed Hooks
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
// store/hooks.ts
|
|
69
|
+
import { useDispatch, useSelector, useStore } from 'react-redux';
|
|
70
|
+
import type { AppDispatch, AppStore, RootState } from './index';
|
|
71
|
+
|
|
72
|
+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
|
73
|
+
export const useAppSelector = useSelector.withTypes<RootState>();
|
|
74
|
+
export const useAppStore = useStore.withTypes<AppStore>();
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Provider Setup
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// app/providers.tsx
|
|
81
|
+
'use client';
|
|
82
|
+
|
|
83
|
+
import { useRef } from 'react';
|
|
84
|
+
import { Provider } from 'react-redux';
|
|
85
|
+
import { makeStore, AppStore } from '@/store';
|
|
86
|
+
|
|
87
|
+
export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
88
|
+
const storeRef = useRef<AppStore>();
|
|
89
|
+
|
|
90
|
+
if (!storeRef.current) {
|
|
91
|
+
storeRef.current = makeStore();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return <Provider store={storeRef.current}>{children}</Provider>;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
// app/layout.tsx
|
|
100
|
+
import { StoreProvider } from './providers';
|
|
101
|
+
|
|
102
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
103
|
+
return (
|
|
104
|
+
<html>
|
|
105
|
+
<body>
|
|
106
|
+
<StoreProvider>{children}</StoreProvider>
|
|
107
|
+
</body>
|
|
108
|
+
</html>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Creating Slices
|
|
114
|
+
|
|
115
|
+
### Basic Slice
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// slices/user-slice.ts
|
|
119
|
+
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
|
120
|
+
|
|
121
|
+
interface User {
|
|
122
|
+
id: string;
|
|
123
|
+
name: string;
|
|
124
|
+
email: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface UserState {
|
|
128
|
+
currentUser: User | null;
|
|
129
|
+
isAuthenticated: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const initialState: UserState = {
|
|
133
|
+
currentUser: null,
|
|
134
|
+
isAuthenticated: false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const userSlice = createSlice({
|
|
138
|
+
name: 'user',
|
|
139
|
+
initialState,
|
|
140
|
+
reducers: {
|
|
141
|
+
setUser: (state, action: PayloadAction<User>) => {
|
|
142
|
+
state.currentUser = action.payload;
|
|
143
|
+
state.isAuthenticated = true;
|
|
144
|
+
},
|
|
145
|
+
logout: (state) => {
|
|
146
|
+
state.currentUser = null;
|
|
147
|
+
state.isAuthenticated = false;
|
|
148
|
+
},
|
|
149
|
+
updateProfile: (state, action: PayloadAction<Partial<User>>) => {
|
|
150
|
+
if (state.currentUser) {
|
|
151
|
+
state.currentUser = { ...state.currentUser, ...action.payload };
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
export const { setUser, logout, updateProfile } = userSlice.actions;
|
|
158
|
+
|
|
159
|
+
// Selectors
|
|
160
|
+
export const selectCurrentUser = (state: RootState) => state.user.currentUser;
|
|
161
|
+
export const selectIsAuthenticated = (state: RootState) => state.user.isAuthenticated;
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Slice with Async Thunks
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// slices/cart-slice.ts
|
|
168
|
+
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
|
169
|
+
|
|
170
|
+
interface CartItem {
|
|
171
|
+
id: string;
|
|
172
|
+
name: string;
|
|
173
|
+
price: number;
|
|
174
|
+
quantity: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface CartState {
|
|
178
|
+
items: CartItem[];
|
|
179
|
+
isLoading: boolean;
|
|
180
|
+
error: string | null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const initialState: CartState = {
|
|
184
|
+
items: [],
|
|
185
|
+
isLoading: false,
|
|
186
|
+
error: null,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// Async thunk
|
|
190
|
+
export const fetchCart = createAsyncThunk(
|
|
191
|
+
'cart/fetchCart',
|
|
192
|
+
async (userId: string, { rejectWithValue }) => {
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(`/api/cart/${userId}`);
|
|
195
|
+
if (!response.ok) throw new Error('Failed to fetch cart');
|
|
196
|
+
return response.json();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
return rejectWithValue('Failed to fetch cart');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
export const cartSlice = createSlice({
|
|
204
|
+
name: 'cart',
|
|
205
|
+
initialState,
|
|
206
|
+
reducers: {
|
|
207
|
+
addItem: (state, action: PayloadAction<CartItem>) => {
|
|
208
|
+
const existingItem = state.items.find((item) => item.id === action.payload.id);
|
|
209
|
+
if (existingItem) {
|
|
210
|
+
existingItem.quantity += action.payload.quantity;
|
|
211
|
+
} else {
|
|
212
|
+
state.items.push(action.payload);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
removeItem: (state, action: PayloadAction<string>) => {
|
|
216
|
+
state.items = state.items.filter((item) => item.id !== action.payload);
|
|
217
|
+
},
|
|
218
|
+
updateQuantity: (state, action: PayloadAction<{ id: string; quantity: number }>) => {
|
|
219
|
+
const item = state.items.find((item) => item.id === action.payload.id);
|
|
220
|
+
if (item) {
|
|
221
|
+
item.quantity = action.payload.quantity;
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
clearCart: (state) => {
|
|
225
|
+
state.items = [];
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
extraReducers: (builder) => {
|
|
229
|
+
builder
|
|
230
|
+
.addCase(fetchCart.pending, (state) => {
|
|
231
|
+
state.isLoading = true;
|
|
232
|
+
state.error = null;
|
|
233
|
+
})
|
|
234
|
+
.addCase(fetchCart.fulfilled, (state, action) => {
|
|
235
|
+
state.isLoading = false;
|
|
236
|
+
state.items = action.payload;
|
|
237
|
+
})
|
|
238
|
+
.addCase(fetchCart.rejected, (state, action) => {
|
|
239
|
+
state.isLoading = false;
|
|
240
|
+
state.error = action.payload as string;
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
|
|
246
|
+
|
|
247
|
+
// Selectors
|
|
248
|
+
export const selectCartItems = (state: RootState) => state.cart.items;
|
|
249
|
+
export const selectCartTotal = (state: RootState) =>
|
|
250
|
+
state.cart.items.reduce((total, item) => total + item.price * item.quantity, 0);
|
|
251
|
+
export const selectCartItemCount = (state: RootState) =>
|
|
252
|
+
state.cart.items.reduce((count, item) => count + item.quantity, 0);
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## RTK Query (Data Fetching)
|
|
256
|
+
|
|
257
|
+
```typescript
|
|
258
|
+
// api/api-slice.ts
|
|
259
|
+
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
|
260
|
+
|
|
261
|
+
export const apiSlice = createApi({
|
|
262
|
+
reducerPath: 'api',
|
|
263
|
+
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
|
|
264
|
+
tagTypes: ['User', 'Product'],
|
|
265
|
+
endpoints: (builder) => ({
|
|
266
|
+
// Queries
|
|
267
|
+
getUsers: builder.query<User[], void>({
|
|
268
|
+
query: () => '/users',
|
|
269
|
+
providesTags: ['User'],
|
|
270
|
+
}),
|
|
271
|
+
getUser: builder.query<User, string>({
|
|
272
|
+
query: (id) => `/users/${id}`,
|
|
273
|
+
providesTags: (result, error, id) => [{ type: 'User', id }],
|
|
274
|
+
}),
|
|
275
|
+
|
|
276
|
+
// Mutations
|
|
277
|
+
createUser: builder.mutation<User, Partial<User>>({
|
|
278
|
+
query: (body) => ({
|
|
279
|
+
url: '/users',
|
|
280
|
+
method: 'POST',
|
|
281
|
+
body,
|
|
282
|
+
}),
|
|
283
|
+
invalidatesTags: ['User'],
|
|
284
|
+
}),
|
|
285
|
+
updateUser: builder.mutation<User, { id: string; body: Partial<User> }>({
|
|
286
|
+
query: ({ id, body }) => ({
|
|
287
|
+
url: `/users/${id}`,
|
|
288
|
+
method: 'PATCH',
|
|
289
|
+
body,
|
|
290
|
+
}),
|
|
291
|
+
invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
|
|
292
|
+
}),
|
|
293
|
+
deleteUser: builder.mutation<void, string>({
|
|
294
|
+
query: (id) => ({
|
|
295
|
+
url: `/users/${id}`,
|
|
296
|
+
method: 'DELETE',
|
|
297
|
+
}),
|
|
298
|
+
invalidatesTags: ['User'],
|
|
299
|
+
}),
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
export const {
|
|
304
|
+
useGetUsersQuery,
|
|
305
|
+
useGetUserQuery,
|
|
306
|
+
useCreateUserMutation,
|
|
307
|
+
useUpdateUserMutation,
|
|
308
|
+
useDeleteUserMutation,
|
|
309
|
+
} = apiSlice;
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Using in Components
|
|
313
|
+
|
|
314
|
+
### Basic Usage
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
'use client';
|
|
318
|
+
|
|
319
|
+
import { useAppDispatch, useAppSelector } from '@/store/hooks';
|
|
320
|
+
import { selectCurrentUser, logout } from '@/slices/user-slice';
|
|
321
|
+
|
|
322
|
+
export function UserProfile() {
|
|
323
|
+
const dispatch = useAppDispatch();
|
|
324
|
+
const user = useAppSelector(selectCurrentUser);
|
|
325
|
+
|
|
326
|
+
if (!user) return null;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div>
|
|
330
|
+
<h1>{user.name}</h1>
|
|
331
|
+
<p>{user.email}</p>
|
|
332
|
+
<button onClick={() => dispatch(logout())}>Logout</button>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Using RTK Query
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
'use client';
|
|
342
|
+
|
|
343
|
+
import { useGetUsersQuery, useDeleteUserMutation } from '@/api/api-slice';
|
|
344
|
+
|
|
345
|
+
export function UserList() {
|
|
346
|
+
const { data: users, isLoading, error } = useGetUsersQuery();
|
|
347
|
+
const [deleteUser, { isLoading: isDeleting }] = useDeleteUserMutation();
|
|
348
|
+
|
|
349
|
+
if (isLoading) return <div>Loading...</div>;
|
|
350
|
+
if (error) return <div>Error loading users</div>;
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<ul>
|
|
354
|
+
{users?.map((user) => (
|
|
355
|
+
<li key={user.id}>
|
|
356
|
+
{user.name}
|
|
357
|
+
<button
|
|
358
|
+
onClick={() => deleteUser(user.id)}
|
|
359
|
+
disabled={isDeleting}
|
|
360
|
+
>
|
|
361
|
+
Delete
|
|
362
|
+
</button>
|
|
363
|
+
</li>
|
|
364
|
+
))}
|
|
365
|
+
</ul>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Testing
|
|
371
|
+
|
|
372
|
+
### Slice Testing
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { userSlice, setUser, logout, selectCurrentUser } from './user-slice';
|
|
376
|
+
|
|
377
|
+
describe('userSlice', () => {
|
|
378
|
+
const initialState = { currentUser: null, isAuthenticated: false };
|
|
379
|
+
|
|
380
|
+
it('should handle setUser', () => {
|
|
381
|
+
const user = { id: '1', name: 'John', email: 'john@test.com' };
|
|
382
|
+
const state = userSlice.reducer(initialState, setUser(user));
|
|
383
|
+
|
|
384
|
+
expect(state.currentUser).toEqual(user);
|
|
385
|
+
expect(state.isAuthenticated).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should handle logout', () => {
|
|
389
|
+
const loggedInState = {
|
|
390
|
+
currentUser: { id: '1', name: 'John', email: 'john@test.com' },
|
|
391
|
+
isAuthenticated: true,
|
|
392
|
+
};
|
|
393
|
+
const state = userSlice.reducer(loggedInState, logout());
|
|
394
|
+
|
|
395
|
+
expect(state.currentUser).toBeNull();
|
|
396
|
+
expect(state.isAuthenticated).toBe(false);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Component Testing with Store
|
|
402
|
+
|
|
403
|
+
```tsx
|
|
404
|
+
import { render, screen } from '@testing-library/react';
|
|
405
|
+
import { Provider } from 'react-redux';
|
|
406
|
+
import { makeStore } from '@/store';
|
|
407
|
+
import { UserProfile } from './user-profile';
|
|
408
|
+
|
|
409
|
+
function renderWithStore(ui: React.ReactElement, preloadedState = {}) {
|
|
410
|
+
const store = makeStore();
|
|
411
|
+
return render(<Provider store={store}>{ui}</Provider>);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
describe('UserProfile', () => {
|
|
415
|
+
it('should render user info', () => {
|
|
416
|
+
renderWithStore(<UserProfile />);
|
|
417
|
+
// ...
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
## Best Practices
|
|
423
|
+
|
|
424
|
+
- Use typed hooks (`useAppDispatch`, `useAppSelector`)
|
|
425
|
+
- Keep slices focused on a single domain
|
|
426
|
+
- Use RTK Query for server state
|
|
427
|
+
- Use selectors for derived state
|
|
428
|
+
- Don't duplicate Server Component data in Redux
|
|
429
|
+
- Use Redux DevTools for debugging
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*store*.ts"
|
|
4
|
+
- "**/*store*.tsx"
|
|
5
|
+
- "**/stores/**/*.ts"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Zustand State Management
|
|
9
|
+
|
|
10
|
+
Use Zustand for lightweight client-side state management.
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
- Small to medium projects
|
|
15
|
+
- Simple state logic
|
|
16
|
+
- When you want minimal boilerplate
|
|
17
|
+
- Quick prototyping
|
|
18
|
+
|
|
19
|
+
## Store Structure
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
libs/
|
|
23
|
+
[domain]/
|
|
24
|
+
data-access/
|
|
25
|
+
stores/
|
|
26
|
+
user-store.ts
|
|
27
|
+
cart-store.ts
|
|
28
|
+
index.ts
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Creating a Store
|
|
32
|
+
|
|
33
|
+
### Basic Store
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// stores/user-store.ts
|
|
37
|
+
import { create } from 'zustand';
|
|
38
|
+
|
|
39
|
+
interface User {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
email: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface UserState {
|
|
46
|
+
// State
|
|
47
|
+
user: User | null;
|
|
48
|
+
isLoading: boolean;
|
|
49
|
+
error: string | null;
|
|
50
|
+
|
|
51
|
+
// Actions
|
|
52
|
+
setUser: (user: User | null) => void;
|
|
53
|
+
fetchUser: (id: string) => Promise<void>;
|
|
54
|
+
logout: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const useUserStore = create<UserState>((set) => ({
|
|
58
|
+
// Initial state
|
|
59
|
+
user: null,
|
|
60
|
+
isLoading: false,
|
|
61
|
+
error: null,
|
|
62
|
+
|
|
63
|
+
// Actions
|
|
64
|
+
setUser: (user) => set({ user }),
|
|
65
|
+
|
|
66
|
+
fetchUser: async (id) => {
|
|
67
|
+
set({ isLoading: true, error: null });
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`/api/users/${id}`);
|
|
70
|
+
const user = await response.json();
|
|
71
|
+
set({ user, isLoading: false });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
set({ error: 'Failed to fetch user', isLoading: false });
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
logout: () => set({ user: null }),
|
|
78
|
+
}));
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Store with Slices (Large Stores)
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// stores/app-store.ts
|
|
85
|
+
import { create } from 'zustand';
|
|
86
|
+
|
|
87
|
+
// User slice
|
|
88
|
+
interface UserSlice {
|
|
89
|
+
user: User | null;
|
|
90
|
+
setUser: (user: User | null) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const createUserSlice = (set: any): UserSlice => ({
|
|
94
|
+
user: null,
|
|
95
|
+
setUser: (user) => set({ user }),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Cart slice
|
|
99
|
+
interface CartSlice {
|
|
100
|
+
items: CartItem[];
|
|
101
|
+
addItem: (item: CartItem) => void;
|
|
102
|
+
removeItem: (id: string) => void;
|
|
103
|
+
clearCart: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const createCartSlice = (set: any): CartSlice => ({
|
|
107
|
+
items: [],
|
|
108
|
+
addItem: (item) => set((state: any) => ({
|
|
109
|
+
items: [...state.items, item]
|
|
110
|
+
})),
|
|
111
|
+
removeItem: (id) => set((state: any) => ({
|
|
112
|
+
items: state.items.filter((item: CartItem) => item.id !== id)
|
|
113
|
+
})),
|
|
114
|
+
clearCart: () => set({ items: [] }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Combined store
|
|
118
|
+
type AppStore = UserSlice & CartSlice;
|
|
119
|
+
|
|
120
|
+
export const useAppStore = create<AppStore>((...args) => ({
|
|
121
|
+
...createUserSlice(...args),
|
|
122
|
+
...createCartSlice(...args),
|
|
123
|
+
}));
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Store with Persistence
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
import { create } from 'zustand';
|
|
130
|
+
import { persist } from 'zustand/middleware';
|
|
131
|
+
|
|
132
|
+
interface ThemeState {
|
|
133
|
+
theme: 'light' | 'dark';
|
|
134
|
+
toggleTheme: () => void;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const useThemeStore = create<ThemeState>()(
|
|
138
|
+
persist(
|
|
139
|
+
(set) => ({
|
|
140
|
+
theme: 'light',
|
|
141
|
+
toggleTheme: () => set((state) => ({
|
|
142
|
+
theme: state.theme === 'light' ? 'dark' : 'light'
|
|
143
|
+
})),
|
|
144
|
+
}),
|
|
145
|
+
{
|
|
146
|
+
name: 'theme-storage', // localStorage key
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Store with Immer (Complex Updates)
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { create } from 'zustand';
|
|
156
|
+
import { immer } from 'zustand/middleware/immer';
|
|
157
|
+
|
|
158
|
+
interface TodoState {
|
|
159
|
+
todos: Todo[];
|
|
160
|
+
addTodo: (text: string) => void;
|
|
161
|
+
toggleTodo: (id: string) => void;
|
|
162
|
+
updateTodo: (id: string, text: string) => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const useTodoStore = create<TodoState>()(
|
|
166
|
+
immer((set) => ({
|
|
167
|
+
todos: [],
|
|
168
|
+
|
|
169
|
+
addTodo: (text) => set((state) => {
|
|
170
|
+
state.todos.push({ id: Date.now().toString(), text, done: false });
|
|
171
|
+
}),
|
|
172
|
+
|
|
173
|
+
toggleTodo: (id) => set((state) => {
|
|
174
|
+
const todo = state.todos.find((todo) => todo.id === id);
|
|
175
|
+
if (todo) {
|
|
176
|
+
todo.done = !todo.done;
|
|
177
|
+
}
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
updateTodo: (id, text) => set((state) => {
|
|
181
|
+
const todo = state.todos.find((todo) => todo.id === id);
|
|
182
|
+
if (todo) {
|
|
183
|
+
todo.text = text;
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
}))
|
|
187
|
+
);
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Using in Components
|
|
191
|
+
|
|
192
|
+
### Basic Usage
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
'use client';
|
|
196
|
+
|
|
197
|
+
import { useUserStore } from '@/stores/user-store';
|
|
198
|
+
|
|
199
|
+
export function UserProfile() {
|
|
200
|
+
const { user, isLoading, error, fetchUser, logout } = useUserStore();
|
|
201
|
+
|
|
202
|
+
if (isLoading) return <div>Loading...</div>;
|
|
203
|
+
if (error) return <div>Error: {error}</div>;
|
|
204
|
+
if (!user) return <div>Not logged in</div>;
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div>
|
|
208
|
+
<h1>{user.name}</h1>
|
|
209
|
+
<p>{user.email}</p>
|
|
210
|
+
<button onClick={logout}>Logout</button>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Selecting State (Performance)
|
|
217
|
+
|
|
218
|
+
```tsx
|
|
219
|
+
'use client';
|
|
220
|
+
|
|
221
|
+
import { useUserStore } from '@/stores/user-store';
|
|
222
|
+
|
|
223
|
+
export function UserName() {
|
|
224
|
+
// Only re-render when user.name changes
|
|
225
|
+
const userName = useUserStore((state) => state.user?.name);
|
|
226
|
+
|
|
227
|
+
return <span>{userName}</span>;
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Shallow Comparison for Objects
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
'use client';
|
|
235
|
+
|
|
236
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
237
|
+
import { useUserStore } from '@/stores/user-store';
|
|
238
|
+
|
|
239
|
+
export function UserInfo() {
|
|
240
|
+
// Prevent re-render if object reference changes but content is same
|
|
241
|
+
const { name, email } = useUserStore(
|
|
242
|
+
useShallow((state) => ({
|
|
243
|
+
name: state.user?.name,
|
|
244
|
+
email: state.user?.email
|
|
245
|
+
}))
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div>
|
|
250
|
+
<p>{name}</p>
|
|
251
|
+
<p>{email}</p>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Testing
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { act, renderHook } from '@testing-library/react';
|
|
261
|
+
import { useUserStore } from './user-store';
|
|
262
|
+
|
|
263
|
+
describe('useUserStore', () => {
|
|
264
|
+
beforeEach(() => {
|
|
265
|
+
// Reset store before each test
|
|
266
|
+
useUserStore.setState({ user: null, isLoading: false, error: null });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('should set user', () => {
|
|
270
|
+
const { result } = renderHook(() => useUserStore());
|
|
271
|
+
|
|
272
|
+
act(() => {
|
|
273
|
+
result.current.setUser({ id: '1', name: 'John', email: 'john@test.com' });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
expect(result.current.user?.name).toBe('John');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should logout', () => {
|
|
280
|
+
useUserStore.setState({ user: { id: '1', name: 'John', email: 'john@test.com' } });
|
|
281
|
+
|
|
282
|
+
const { result } = renderHook(() => useUserStore());
|
|
283
|
+
|
|
284
|
+
act(() => {
|
|
285
|
+
result.current.logout();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result.current.user).toBeNull();
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Best Practices
|
|
294
|
+
|
|
295
|
+
- Keep stores small and focused
|
|
296
|
+
- Use selectors for performance
|
|
297
|
+
- Don't put Server Component data in stores
|
|
298
|
+
- Use `immer` middleware for complex nested updates
|
|
299
|
+
- Use `persist` middleware for data that should survive page refresh
|