initial commit
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: react-crud-patterns
|
||||
description: "Common patterns and pitfalls in React CRUD applications — state hydration, save/sync routing, edit-path data flow, and relationship tracking."
|
||||
version: 1.0.0
|
||||
author: Hermes Agent
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [react, crud, debugging, state-management, data-flow, edit-path]
|
||||
related_skills: [systematic-debugging, web-ui-repair, test-driven-development]
|
||||
---
|
||||
|
||||
# React CRUD Patterns
|
||||
|
||||
## Overview
|
||||
|
||||
CRUD (Create-Read-Update-Delete) apps have a characteristic data flow that creates repeatable bug classes. The most common is a **state variable initialized from a transient source** (URL query param, navigation state) that is **never re-populated from the persisted backend record** when the record is re-opened for editing. This causes save/sync side-effects to silently do nothing.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Transient State Not Re-Hydrated on Edit
|
||||
|
||||
**Symptom**: A save/submit/sync operation works on newly created records but silently does nothing when editing an existing record. The form loads, data looks right, edits save — but a linked side-effect (syncing to another collection, sending a notification, updating a parent record) never fires.
|
||||
|
||||
**Root cause**: A `useState` variable is initialized from a transient source (URL query param, `location.state`, initial page navigation) but never restored from the backend record when the record is opened in edit mode.
|
||||
|
||||
**Zustand persist middleware makes this harder to spot.** On edit-page mount, the Zustand store rehydrates from localStorage (which has state from when the record was created, including the correct relationship ID and populated data). The component renders with correct-looking data — customer info, services, discount all populated. The developer sees the form looking right and doesn't suspect a missing variable. But the gate variable lives in React `useState`, not in Zustand — so `useQuoteStore.setState()` can't restore it. It stays `null` from the missing URL param, and the sync silently skips.
|
||||
|
||||
**Example flow that breaks:**
|
||||
|
||||
1. User navigates to create page with `?sourceId=xyz` in the URL
|
||||
2. Component sets `const [sourceId, setSourceId] = useState(searchParams.get('sourceId'))`
|
||||
3. Save handler gates a sync: `if (sourceId) { /* sync approved services back to source */ }`
|
||||
4. Record saved in backend with `sourceId` field set to `xyz`
|
||||
5. User re-opens record for editing — URL is now `?edit=rec-456` (no `sourceId` param)
|
||||
6. `sourceId` stays `null` → sync is silently skipped
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
1. Check the URL on the edit view — does it carry the same relationship params as the create URL?
|
||||
2. Search for every `useState` that feeds into save/sync logic. Is it ever set from the loaded record data, or only from initial URL params?
|
||||
3. Read the edit-loading effect — does it read `record.sourceId` and call `setSourceId()`?
|
||||
4. Trace the state through the save handler — `if (sourceId)` gates the whole sync block.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Add the state restoration in the edit-loading code:
|
||||
|
||||
```tsx
|
||||
// In the edit-loading useEffect, after fetching the record:
|
||||
if (data.sourceId) {
|
||||
setSourceId(data.sourceId);
|
||||
}
|
||||
```
|
||||
|
||||
Or from the component props in a parent-routing pattern:
|
||||
|
||||
```tsx
|
||||
// Edit loading derives sourceId from the backend record, not the URL:
|
||||
const loadEditRecord = async (editId: string) => {
|
||||
const record = await pb.collection('records').getOne(editId);
|
||||
const data = record as any;
|
||||
|
||||
// Restore sourceId from persisted data (not from URL params)
|
||||
if (data.sourceId) {
|
||||
setSourceId(data.sourceId);
|
||||
}
|
||||
|
||||
setFormFields({ /* ... */ });
|
||||
};
|
||||
```
|
||||
|
||||
**Prevention checklist:**
|
||||
|
||||
- [ ] Every `useState` that gates a save/sync side-effect has a corresponding `setState` call in the edit-loading code path
|
||||
- [ ] The edit-loading effect reads ALL relationship/foreign-key fields from the backend record, not just display fields
|
||||
- [ ] URL query params are treated as **create-only** sources of state — edit mode derives from the persisted record
|
||||
- [ ] Sync/test scenarios cover the edit-then-save path, not just create-then-save
|
||||
|
||||
### Pattern 2: Relationship ID Stored But Not Read During Edit
|
||||
|
||||
**Symptom**: Records have a `sourceId` / `parentId` / `originId` field stored in the backend, but editors load and save records without preserving or using the link.
|
||||
|
||||
**Root cause**: The edit-loading code only reads display-oriented fields (name, price, description) and ignores relationship fields. The save code then overwrites the record with no relationship link.
|
||||
|
||||
**Fix:** Include the relationship field in both the read and write paths:
|
||||
|
||||
```tsx
|
||||
// Edit loading — read the relationship field:
|
||||
const editRecord = await pb.collection('quotes').getOne(editId);
|
||||
const data = editRecord as any;
|
||||
// Include repairOrderId / sourceId in what's read
|
||||
setCustomerInfo({ roNumber: data.roNumber || '' });
|
||||
|
||||
// Save — preserve the relationship field:
|
||||
const data: Record<string, any> = {
|
||||
...formFields,
|
||||
sourceId: sourceId || originalRecord.sourceId, // fallback to original
|
||||
};
|
||||
```
|
||||
|
||||
### Pattern 3: Multi-Path Sync (Create vs Share vs Save All Do the Same Thing Differently)
|
||||
|
||||
**Symptom**: Three different user actions (Save, Share, Download PDF) all need to sync approved services back to a source record, but the sync code is copy-pasted in three places. One gets updated, the others don't.
|
||||
|
||||
**Fix:** Extract the sync logic into a shared function:
|
||||
|
||||
```tsx
|
||||
async function syncApprovedServicesToOrigin(
|
||||
repairOriginId: string,
|
||||
services: QuoteService[],
|
||||
settings: ShopSettings,
|
||||
) {
|
||||
const approved = services.filter((s) => s.approved);
|
||||
if (approved.length === 0) return;
|
||||
|
||||
const ro = await pb.collection('repairOrders').getOne(repairOriginId);
|
||||
let roServices = parseServices(ro.services);
|
||||
// ... merge approved services ...
|
||||
await pb.collection('repairOrders').update(repairOriginId, {
|
||||
services: JSON.stringify(roServices),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Investigation Workflow
|
||||
|
||||
When investigating a CRUD data-flow bug:
|
||||
|
||||
1. **Map the state sources**: For each `useState` involved in save/sync, identify ALL code paths that set it (URL params, API response, parent props, form inputs, localStorage).
|
||||
2. **Distinguish `useState` from Zustand store state**: Variables in React `useState` are NOT restored by `useQuoteStore.setState()` — they need their own explicit `setState()`. Variables in the Zustand store CAN be restored by the store's `setState()`. Grep for `const \\[.*, set.*\\] = useState` to find React state, and `const {.*} = use.*Store()` for Zustand state.
|
||||
3. **Check the edit path**: Does the edit-loading code path set every state variable that the create-loading code path sets? Cross-reference with step 2 — `useState` variables are the most commonly missed.
|
||||
4. **Check the save path**: Does the save handler gate side-effects on state that could be null on edit? If so, add a fallback from the loaded record.
|
||||
5. **Verify with the user**: If the symptom is "works on create but not edit", explain this class of bug and ask if they want you to trace the specific state variable.
|
||||
|
||||
### Multi-Layer Investigation: UI → API → Database
|
||||
|
||||
When a CRUD bug appears to still exist AFTER applying a structural fix (e.g., you added the missing `setState` call but the sync still doesn't fire), the issue may be at a different layer. Isolate it systematically:
|
||||
|
||||
**Layer 1 — Browser UI / Console:**
|
||||
- Verify the action fires: check `browser_console()` for errors and debug logs after clicking the button
|
||||
- Inject console.log markers (`[SPQ-DEBUG]` prefix) at every decision point in the suspect code path
|
||||
- Check the page snapshot — did the button actually appear as enabled (not `disabled`)? Did a Toast appear?
|
||||
- Compare in-memory state (Zustand store `getState()`) with persisted backend state
|
||||
|
||||
**Layer 2 — Direct API calls (bypass the UI):**
|
||||
- Authenticate with the exact same credentials the frontend uses
|
||||
- Manually perform the exact API call the sync code makes, with the exact same payload
|
||||
- If the API call works, the code logic is correct — the issue is in the UI execution path (button not firing, wrong state at time of click, race condition)
|
||||
- If the API call FAILS, the issue is server-side (auth, permissions, schema validation, API rules)
|
||||
|
||||
**Layer 3 — Database / Server state:**
|
||||
- Query PocketBase records directly to compare frontend-visible state vs persisted state
|
||||
- If the frontend shows correct state but PB still has the old data, the save handler never completed or the sync threw a caught exception
|
||||
- Create test data (RO + child record) owned by the same user to isolate ownership variables
|
||||
|
||||
**Layer 4 — Auth / Ownership:**
|
||||
- PocketBase's `userId = @request.auth.id` rule makes records invisible/unwritable to other users
|
||||
- A 404 on GET or `Failed to authenticate` on update usually means the authenticated user doesn't own the record
|
||||
- Always authenticate with the record owner's credentials when testing API calls — not an admin token, not a different user
|
||||
- Verify ownership: check the `userId` field on the source record and the target record — they must match
|
||||
- A sync that works when tested via curl but fails in the UI often means the UI's auth token belongs to a different user than the one who owns the data
|
||||
|
||||
**When to use this workflow:** Any time you've applied a fix that "should work" based on code inspection but the observable behavior hasn't changed. Each layer eliminates a category of root causes.
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `systematic-debugging` — General 4-phase debugging methodology. Use this when the root cause isn't clearly a CRUD state-flow issue.
|
||||
- `web-ui-repair` — Fix common static web app UI issues (display, styling, event handlers).
|
||||
- `test-driven-development` — Write regression tests for CRUD state-flow fixes.
|
||||
Reference in New Issue
Block a user