Files
2026-07-12 10:17:17 -04:00

171 lines
9.5 KiB
Markdown

---
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.