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