9.5 KiB
name, description, version, author, license, metadata
| name | description | version | author | license | metadata | |||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| react-crud-patterns | Common patterns and pitfalls in React CRUD applications — state hydration, save/sync routing, edit-path data flow, and relationship tracking. | 1.0.0 | Hermes Agent | MIT |
|
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:
- User navigates to create page with
?sourceId=xyzin the URL - Component sets
const [sourceId, setSourceId] = useState(searchParams.get('sourceId')) - Save handler gates a sync:
if (sourceId) { /* sync approved services back to source */ } - Record saved in backend with
sourceIdfield set toxyz - User re-opens record for editing — URL is now
?edit=rec-456(nosourceIdparam) sourceIdstaysnull→ sync is silently skipped
Diagnosis:
- Check the URL on the edit view — does it carry the same relationship params as the create URL?
- Search for every
useStatethat feeds into save/sync logic. Is it ever set from the loaded record data, or only from initial URL params? - Read the edit-loading effect — does it read
record.sourceIdand callsetSourceId()? - Trace the state through the save handler —
if (sourceId)gates the whole sync block.
Fix:
Add the state restoration in the edit-loading code:
// 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:
// 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
useStatethat gates a save/sync side-effect has a correspondingsetStatecall 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:
// 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:
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:
- Map the state sources: For each
useStateinvolved in save/sync, identify ALL code paths that set it (URL params, API response, parent props, form inputs, localStorage). - Distinguish
useStatefrom Zustand store state: Variables in ReactuseStateare NOT restored byuseQuoteStore.setState()— they need their own explicitsetState(). Variables in the Zustand store CAN be restored by the store'ssetState(). Grep forconst \\[.*, set.*\\] = useStateto find React state, andconst {.*} = use.*Store()for Zustand state. - 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 —
useStatevariables are the most commonly missed. - 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.
- 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.idrule makes records invisible/unwritable to other users - A 404 on GET or
Failed to authenticateon 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
userIdfield 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.