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