What you'll learn: This comprehensive guide walks through implementing a delete feature in a React + Remix application, battling the infamous "Zombie Row" bug, and discovering fundamental state management patterns. Perfect for developers learning React or deepening their understanding of state ownership, hooks, and component architecture.
⚠️ A Confession Before We Begin
Full transparency: This bug happened because I jumped into React and Remix development with a backend developer mindset, without properly learning the fundamentals first.
I thought: "I know JavaScript, I've built APIs, how hard can frontend be?"
Spoiler alert: Very hard when you skip the learning phase.
This article chronicles my journey from:
- ❌ Treating React components like API endpoints
- ❌ Fighting the framework instead of embracing it
- ❌ Copy-pasting patterns I didn't understand
- ❌ Debugging by trial and error
To finally:
- ✅ Understanding React's mental model
- ✅ Learning proper state management patterns
- ✅ Writing maintainable, testable code
If you're also transitioning from backend to frontend (or just struggling with React state), this is for you. Every mistake I made, you can avoid. Every lesson I learned the hard way, you can learn the easy way.
Let's dive in. 👇
📖 What We'll Cover
- The Problem - Understanding the "Zombie Row" bug
- The Infrastructure - GraphQL, Remix, and React architecture
- The Toolbox - React Hooks explained from first principles
- The Battlefield - Three failed attempts (and why)
- The Victory - The Container/Presentational solution
- The Lessons - Key takeaways and best practices
🧟 Chapter 1: The Problem (The "Zombie Row")
The Backend Perspective
As a backend engineer, DELETE is straightforward:
- Client sends
DELETE /api/items/123 - Server removes record from database
- Server returns
200 OK - Done. The data is gone.
But in modern frontend applications with client-side state management, the story is different. The browser maintains its own copy of the data—a cache—and that cache doesn't automatically know when the server has deleted something.
The Scenario
You're building a data management interface with a table display. The page shows a list of items:
| Name | Description | Actions |
|---|---|---|
| Item A | Full Access | 👁️ Edit Delete |
| Item B | Limited | 👁️ Edit 🗑️ |
| Item C | Read Only | 👁️ Edit 🗑️ |
The user workflow:
- User clicks the 🗑️ (delete icon) on "Item B"
- Confirmation dialog appears: "Delete Item B?"
- User clicks "Delete"
- Network request fires:
DELETE /api/items/456 - Server responds:
{ "success": true } - Success toast appears: ✅ "Item B has been deleted"
The Bug: The Zombie Row
Expected Behavior:
- The "Item B" row immediately disappears from the table
- Table now shows only "Item A" and "Item C"
Actual Behavior:
- The success toast appears
- The "Item B" row is STILL in the table
- User confusion: "Wait, did it actually delete?"
- User refreshes the page (F5)
- NOW the row is gone
This is what we call a Zombie Row—data that is dead in the database but still alive on the screen.
Why Did This Happen?
The bug stemmed from a fundamental architectural problem in how our DataTable component was designed. Let me show you the code:
// ❌ THE PROBLEMATIC ARCHITECTURE
// CHILD COMPONENT: DataTable.tsx
export function DataTable({ connection }) {
// The Table owns its own internal copy of the data
const [edges, setEdges] = useState(connection.edges);
return (
<table>
{edges.map(edge => (
<ItemRow key={edge.node.id} item={edge.node} />
))}
</table>
);
}
// PARENT COMPONENT: ItemsPage.tsx
export default function ItemsPage() {
const data = useLoaderData(); // Initial data from server
const handleDeleteSuccess = () => {
// ❌ PROBLEM: I know the item was deleted...
// But how do I tell DataTable to remove it from its internal state?
// The Table is holding its own private copy of `edges`!
};
return <DataTable connection={data.connection} />;
}
The Core Problem:
-
Child Owns the State:
DataTablehas its ownuseState(connection.edges)- it made a private copy of the data when it first rendered - Parent Can't Control Child: Even though the parent knows a item was deleted, it has no way to tell the child's internal state to update
-
Props Don't Re-trigger State: Passing new props doesn't automatically reset a child's
useStatevalue - Stale Cache: The table keeps showing its original cached data until the entire page refreshes
💡 Mental Model: Think of it like this: The parent is a manager who knows an employee left the company, but the reception desk (the child component) is still showing the old employee directory because they printed it out on Day 1 and never check for updates.
The Real-World Impact
This wasn't just a visual glitch. It caused:
- ❌ User Trust Issues: "Did my action actually work?"
- ❌ Support Tickets: Users reporting "delete isn't working"
- ❌ Workaround Behavior: Users developed the habit of refreshing after every delete
- ❌ Data Confusion: Users would try to edit a "deleted" item thinking it was still active
⚠️ The Bottom Line: We needed to fix this. But first, I needed to understand the infrastructure I was working with.
⚙️ Chapter 2: The Infrastructure (Understanding the Stack)
Before attempting fixes, I needed to understand the tools I was working with. Coming from a backend background, React's ecosystem felt like learning a new language.
🏗️ The Data Flow Architecture
Our application uses a specific tech stack:
Server (GraphQL API)
↓
Remix (Server-Side Framework)
↓
React (UI Library)
↓
Browser (Client Cache)
Let me break down each layer:
1. GraphQL with Relay-Style Connections
GraphQL is our API query language, but we use a specific pattern called Relay Connections for pagination.
What is a Connection?
Instead of returning a simple array of items, our API returns a structured object:
// ❌ Simple Array (Not Scalable)
{
"items": [
{ "id": "1", "name": "Item A" },
{ "id": "2", "name": "Item B" }
]
}
// ✅ Relay Connection (Scalable Pagination)
{
"connection": {
"edges": [
{
"cursor": "Y3Vyc29yOjE=", // A bookmark for this position
"node": { "id": "1", "name": "Item A" }
},
{
"cursor": "Y3Vyc29yOjI=",
"node": { "id": "2", "name": "Item B" }
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "Y3Vyc29yOjI="
}
}
}
Understanding the Parts:
- Node: The actual data object (the Item itself)
- Edge: A wrapper around the Node that includes pagination metadata
- Cursor: A unique bookmark (Base64 encoded) that marks this item's position in the full list
- PageInfo: Tells us if there are more pages and what cursor to use for "Load More"
Why This Structure?
When the user clicks "Load More," we send:
query GetItems($after: String) {
items(first: 20, after: "Y3Vyc29yOjI=") {
edges { cursor node { id name } }
pageInfo { hasNextPage endCursor }
}
}
The server says: "Give me 20 items AFTER this bookmark." This is more efficient than offset-based pagination (LIMIT 20 OFFSET 40) because cursors work with sorted, filtered data.
2. Remix: The Server-Client Bridge
Remix is a React framework that handles routing and data fetching. Key concepts:
Loaders (Read Operations)
A loader runs on the server before the page renders. It fetches initial data:
export async function loader({ params }: LoaderFunctionArgs) {
// This runs on the SERVER before React renders
const result = await graphqlClient.query(GET_ITEMS_QUERY, {
variables: {
id: params.id,
first: 20 // Initial page size
}
});
return { connection: result.data.items };
// Remix serializes this and sends it to the browser
}
Actions (Write Operations)
An action runs on the server when a form is submitted (like our delete operation):
export async function action({ params }: ActionFunctionArgs) {
// This runs on the SERVER when delete is triggered
const result = await graphqlClient.mutate(DELETE_ITEM_MUTATION, {
variables: { id: params.itemId }
});
if (result.errors) {
return { success: false, error: "Delete failed" };
}
return { success: true };
}
useFetcher (The Communication Robot)
This is a hook that creates a "mini-robot" to talk to actions WITHOUT navigating away from the page:
import { useFetcher } from "@remix-run/react";
function DeleteButton({ itemId }) {
const fetcher = useFetcher();
const handleDelete = () => {
// Sends request to the action at this path
fetcher.submit(
{ itemId }, // Form data
{ method: "delete", action: `/api/items/${itemId}` }
);
};
// Track the state
console.log(fetcher.state); // "idle" | "submitting" | "loading"
console.log(fetcher.data); // The response from action
return (
<button onClick={handleDelete} disabled={fetcher.state !== "idle"}>
{fetcher.state === "submitting" ? "Deleting..." : "Delete"}
</button>
);
}
Fetcher States:
- idle: Nothing happening, ready for new request
- submitting: Request is being sent to server
- loading: Server responded, Remix is processing
-
idle (again): Done,
fetcher.datacontains response
This was KEY to understanding why my first attempts failed. The fetcher state is TRANSIENT—it clears after completing!
3. React Component Architecture
Our page uses a component hierarchy:
ItemsPage (Parent/Container)
├── DeleteDialog
│ └── FetcherButton
└── DataTable (Child/Presentation)
├── TableHeader
└── ItemRow (repeated)
├── ViewIcon
├── EditIcon
└── DeleteIcon
The Flow:
- User clicks DeleteIcon in an ItemRow
- ItemsPage shows DeleteDialog
- User confirms → Dialog submits via fetcher
- Action runs on server → returns success
- PROBLEM: How does DataTable know to remove the row?
This is where understanding state ownership became critical.
🧰 Chapter 3: The Toolbox (React Hooks Explained)
React hooks are functions that let you "hook into" React features. Coming from backend, think of them as lifecycle event handlers.
🪝 1. useState (The State Box)
This creates a piece of state that persists across re-renders:
const [count, setCount] = useState(0);
// Reading state
console.log(count); // 0
// Updating state (triggers re-render)
setCount(1);
console.log(count); // Still 0! (will be 1 on next render)
// Functional update (safe for loops)
setCount(prev => prev + 1); // Uses latest value
⚠️ CRITICAL RULE: Setting state doesn't change it immediately. React schedules a re-render with the new value.
Backend Analogy: Like a database transaction—you queue the change, and it applies after commit (re-render).
🪝 2. useEffect (The Side-Effect Runner)
Runs code AFTER React finishes rendering the UI:
useEffect(() => {
// This runs AFTER the DOM is updated
console.log("Component rendered!");
// Optional cleanup (runs before next effect or unmount)
return () => {
console.log("Cleaning up!");
};
}, [dependency]); // Only re-run if this value changes
The Dependency Array (the second argument) is crucial:
// ❌ No deps array: Runs after EVERY render (usually wrong)
useEffect(() => {
fetchData();
});
// ✅ Empty array: Runs ONCE on mount
useEffect(() => {
fetchData();
}, []);
// ✅ With deps: Runs when deps change
useEffect(() => {
fetchData(userId);
}, [userId]); // Re-run when userId changes
Common Pitfall (This bit me hard):
const [data, setData] = useState([]);
useEffect(() => {
setData([...data, newItem]); // ❌ Reading `data`
}, [data]); // ❌ `data` is a dependency
// This creates an INFINITE LOOP:
// setData → data changes → useEffect runs → setData → data changes → ...
Backend Analogy: Like a database trigger that fires on UPDATE—if the trigger itself causes an UPDATE, you get infinite recursion.
🪝 3. useRef (The Persistent Box)
A box that holds a value that DOESN'T trigger re-renders when changed:
const countRef = useRef(0);
// Reading
console.log(countRef.current); // 0
// Updating (NO re-render triggered!)
countRef.current = 10;
console.log(countRef.current); // 10 immediately
// Persists across renders
function Component() {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1; // Track renders without causing more renders
});
return <div>Rendered {renderCount.current} times</div>;
}
Common Uses:
-
Timers:
const timeoutRef = useRef(null); timeoutRef.current = setTimeout(...) -
DOM References:
const inputRef = useRef(); <input ref={inputRef} /> -
Previous Values:
const prevPropsRef = useRef(); prevPropsRef.current = props -
Flags:
const isProcessedRef = useRef(false)(prevent double-processing)
Backend Analogy: Like a static variable in a class—persists across method calls but doesn't trigger events.
🪝 4. useMemo (The Cached Calculation)
Remembers the result of an expensive calculation:
const expensiveValue = useMemo(() => {
console.log("Calculating...");
return data.map(item => transform(item));
}, [data]); // Only recalculate if `data` changes
// Without useMemo, this would recalculate on EVERY render
// With useMemo, it only recalculates when `data` actually changes
When to Use:
- Expensive transformations (filtering large arrays)
- Preventing child re-renders (when passing objects/arrays as props)
When NOT to Use:
- Simple calculations (overhead not worth it)
- When dependencies change frequently anyway
Backend Analogy: Like Redis caching—calculate once, use many times until invalidation.
🪝 5. useCallback (The Cached Function)
Similar to useMemo, but for functions:
const handleClick = useCallback(() => {
console.log("Clicked", id);
}, [id]); // Only create new function if `id` changes
// Without useCallback, a new function is created every render
// This matters when passing to child components (causes re-renders)
Why It Matters:
// ❌ Without useCallback
function Parent() {
const handleClick = () => console.log("Click"); // New function every render
return <Child onClick={handleClick} />; // Child re-renders every time
}
// ✅ With useCallback
function Parent() {
const handleClick = useCallback(() => console.log("Click"), []); // Same function
return <Child onClick={handleClick} />; // Child doesn't re-render unnecessarily
}
🪝 6. useFetcher (Remix-Specific)
Already covered above, but worth emphasizing: This is how we communicate with server actions without page navigation.
⚔️ Chapter 4: The Battlefield (Failed Attempts)
Armed with (minimal) knowledge of these hooks, I started attempting to fix the zombie row bug. Each attempt taught me something valuable about React's state management.
Attempt #1: Optimistic UI with Intents (❌ Failed)
Commit: 3dc0453e - "Removing the deleted item from ui using optimistic ui and intents"
Date: January 21, 2026
Duration: 3 hours of confusion
Understanding "Intent" Pattern
Before diving into what I tried, let me explain what an Intent is in Remix.
What is an Intent?
An intent is a string identifier you pass in formData to tell an action what operation to perform. It's like a command code.
General Usage:
// SCENARIO: One action handles multiple operations
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
switch (intent) {
case "create-item":
return createItem(formData);
case "update-item":
return updateItem(formData);
case "delete-item":
return deleteItem(formData);
default:
throw new Error("Unknown intent");
}
}
// IN THE COMPONENT:
function MyComponent() {
const fetcher = useFetcher();
const handleCreate = () => {
fetcher.submit(
{ intent: "create-item", name: "Manager" },
{ method: "post" }
);
};
const handleDelete = () => {
fetcher.submit(
{ intent: "delete-item", id: "123" },
{ method: "post" }
);
};
}
Why Use Intents?
- Single action endpoint handles multiple operations
- Cleaner routing (one route vs. many)
- Easier to share logic between operations
Understanding "Optimistic UI"
Optimistic UI means updating the UI BEFORE the server confirms the operation succeeded. You "optimistically" assume it will work.
General Pattern:
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Buy milk", done: false },
{ id: 2, text: "Walk dog", done: false }
]);
const fetcher = useFetcher();
const toggleTodo = (id: number) => {
// ✅ OPTIMISTIC: Update UI immediately
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
// THEN send to server (might fail!)
fetcher.submit(
{ intent: "toggle-todo", id },
{ method: "post" }
);
};
// If server fails, rollback:
useEffect(() => {
if (fetcher.data?.error) {
// Revert the optimistic change
setTodos(prevTodos => /* restore original state */);
}
}, [fetcher.data]);
return (
<ul>
{todos.map(todo => (
<li
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
>
{todo.text}
</li>
))}
</ul>
);
}
The UX Benefit:
- User clicks → Instant feedback (no waiting for server)
- Feels snappy and responsive
- If server fails, you rollback and show error
When It Works Best:
- Toggles (like/unlike, complete/incomplete)
- Non-destructive operations
- Operations with high success rate
When It's Risky:
- Deletions (hard to rollback psychologically)
- Critical operations (payments, permissions)
- Complex validations (server might reject)
Understanding Common UI Component Patterns
Before diving into the implementation, let's understand the key UI components used:
Modal/Dialog Component Pattern
A Dialog is a component that overlays the main UI to demand user attention:
// Generic Dialog Component
interface DialogProps {
isOpen: boolean; // Controls visibility
onClose: () => void; // Called when user dismisses
title?: string;
children: React.ReactNode; // The content inside
}
function Dialog({ isOpen, onClose, title, children }: DialogProps) {
if (!isOpen) return null; // Don't render if closed
return (
<div className="overlay" onClick={onClose}>
<div className="dialog" onClick={e => e.stopPropagation()}>
{title && <h2>{title}</h2>}
{children}
<button onClick={onClose}>Cancel</button>
</div>
</div>
);
}
// Usage Example
function MyComponent() {
const [showDialog, setShowDialog] = useState(false);
return (
<>
<button onClick={() => setShowDialog(true)}>Open</button>
<Dialog
isOpen={showDialog}
onClose={() => setShowDialog(false)}
title="Confirm Action"
>
<p>Are you sure?</p>
<button onClick={() => {
performAction();
setShowDialog(false);
}}>
Yes, Continue
</button>
</Dialog>
</>
);
}
Key Principles:
- Parent owns the
isOpenstate - Dialog is controlled (doesn't manage its own visibility)
-
onClosecallback lets parent handle dismissal - Click overlay to close (UX standard)
Table Component Pattern
Tables display lists of data with consistent formatting:
// Generic Table Component
interface TableProps<T> {
data: T[]; // Array of items to display
renderRow: (item: T) => JSX.Element; // How to render each row
columns: string[]; // Column headers
loading?: boolean;
}
function Table<T>({ data, renderRow, columns, loading }: TableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(col => <th key={col}>{col}</th>)}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={columns.length}>Loading...</td></tr>
) : (
data.map(renderRow)
)}
</tbody>
</table>
);
}
// Usage Example
function UserList() {
const [users, setUsers] = useState([]);
const renderUserRow = (user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
<td>
<button onClick={() => deleteUser(user.id)}>Delete</button>
</td>
</tr>
);
return (
<Table
data={users}
renderRow={renderUserRow}
columns={['Name', 'Email', 'Actions']}
/>
);
}
Key Principles:
- Parent owns the data
- Table is presentational (just renders what it's given)
-
renderRowgives parent control over formatting - Table doesn't know about business logic (deleting, editing, etc.)
Toast/Notification Component Pattern
Toasts show temporary feedback messages that auto-dismiss:
// Generic Toast Component
interface ToastProps {
message: string;
type: 'success' | 'error' | 'info';
isVisible: boolean;
onDismiss: () => void;
duration?: number; // Auto-dismiss after X milliseconds
}
function Toast({ message, type, isVisible, onDismiss, duration = 5000 }: ToastProps) {
useEffect(() => {
if (isVisible && duration > 0) {
const timer = setTimeout(onDismiss, duration);
return () => clearTimeout(timer); // Cleanup
}
}, [isVisible, duration, onDismiss]);
if (!isVisible) return null;
return (
<div className={`toast toast-${type}`}>
<span>{message}</span>
<button onClick={onDismiss}>✕</button>
</div>
);
}
// Usage Example
function MyComponent() {
const [toast, setToast] = useState({ visible: false, message: '', type: 'info' });
const showSuccess = (message: string) => {
setToast({ visible: true, message, type: 'success' });
};
return (
<>
<button onClick={() => showSuccess('Action completed!')}>
Do Something
</button>
<Toast
isVisible={toast.visible}
message={toast.message}
type={toast.type}
onDismiss={() => setToast(prev => ({ ...prev, visible: false }))}
duration={5000} // 5 seconds
/>
</>
);
}
Key Principles:
- Auto-dismiss with setTimeout (accessibility: users need time to read)
- Manual dismiss button (accessibility: user control)
- Parent manages visibility state
- Different types for different contexts (success/error/info)
My First Attempt: Intent-Based Optimistic Delete
I thought: "I'll use the intent pattern to hide the row immediately, before the server responds!"
The Code:
// ========================================
// FILE: pages/items/index.tsx
// ========================================
function ItemsPage() {
const data = useLoaderData();
const fetcher = useFetcher();
// Try to determine if a delete is in progress
const deletingItemId = fetcher.formData?.get("itemId");
return (
<DataTable
connection={data.connection}
deletingId={deletingItemId} // Pass "intent" to child
/>
);
}
// ========================================
// FILE: components/DataTable.tsx
// ========================================
function DataTable({ connection, deletingId }) {
// Filter out the item being deleted
const visibleEdges = connection.edges.filter(edge => {
// If this item is currently being deleted, hide it
if (deletingId && edge.node.id === deletingId) {
return false; // Don't show this row
}
return true; // Show all other rows
});
return (
<table>
{visibleEdges.map(edge => (
<ItemRow key={edge.node.id} item={edge.node} />
))}
</table>
);
}
// ========================================
// FILE: app/domains/panels/components/ItemDeleteDialog/index.tsx
// ========================================
function ItemDeleteDialog({ itemId, isOpen, onClose }) {
const fetcher = useFetcher();
const handleDelete = () => {
// Submit with intent
fetcher.submit(
{ itemId, intent: "delete-item" },
{ method: "delete", action: `/api/items/${itemId}` }
);
};
return (
<Dialog isOpen={isOpen} onClose={onClose}>
<button onClick={handleDelete}>Delete</button>
</Dialog>
);
}
What I Expected to Happen
- User clicks delete → Dialog submits with
{ itemId: "123", intent: "delete-item" } -
Immediately,
fetcher.formDatacontains this data - Parent reads
deletingItemId = "123" - DataTable filters out item "123"
- Row disappears instantly (optimistic!)
- Server processes deletion
- Success! Row stays gone.
What Actually Happened (The Bug)
Timeline:
─────────────────────────────────────────────────────
T=0ms: User clicks "Delete" button
T=1ms: fetcher.submit() called
fetcher.state = "submitting"
fetcher.formData = { itemId: "123", intent: "delete-item" }
T=2ms: Parent re-renders
deletingItemId = "123" ← Reads from formData
T=3ms: DataTable re-renders
Filters out edge with id="123"
Row disappears ✅ (looks good!)
T=200ms: Network request completes
Server responds: { success: true }
fetcher.state = "loading"
T=205ms: Remix processes response
fetcher.state = "idle" ← Back to idle!
fetcher.formData = undefined ← CLEARED!
fetcher.data = { success: true }
T=206ms: Parent re-renders
deletingItemId = undefined ← formData is gone!
T=207ms: DataTable re-renders
No longer filtering anything
Row reappears! ❌❌❌ ZOMBIE IS BACK!
Why It Failed: The Fetcher Lifecycle
The problem is that fetcher.formData is transient. Remix clears it once the request completes. Here's the lifecycle:
// FETCHER LIFECYCLE:
// Before submit:
fetcher.state = "idle"
fetcher.formData = undefined
fetcher.data = undefined
// After fetcher.submit():
fetcher.state = "submitting"
fetcher.formData = { itemId: "123" } ← Available here
fetcher.data = undefined
// Server responding:
fetcher.state = "loading"
fetcher.formData = { itemId: "123" } ← Still available
fetcher.data = undefined
// After server responds:
fetcher.state = "idle"
fetcher.formData = undefined ← ❌ CLEARED!
fetcher.data = { success: true } ← Response is here
So my logic was:
const deletingItemId = fetcher.formData?.get("itemId");
// This is only defined DURING submission
// Once the request completes, it becomes undefined
// → The filter stops working
// → The row reappears
Additional Problems I Encountered
Problem 1: The "Flicker"
The row would:
- Disappear (when
formDataexists) - Reappear for ~200ms (when
formDataclears but before success logic runs) - Disappear again (when success logic finally hides it)
Users saw a flicker—the zombie briefly came back to life.
Problem 2: Load More Confusion
// If user clicked "Load More" after deleting:
useEffect(() => {
if (fetcher.data?.connection) {
setEdges(prev => [...prev, ...fetcher.data.connection.edges]);
// ❌ New edges don't know about the deleted item
// ❌ If deleted item was on page 2, it comes back!
}
}, [fetcher.data]);
Problem 3: Multiple Rapid Deletes
// User deletes item "123"
deletingItemId = "123" // Only tracks ONE at a time
// Before that finishes, user deletes item "456"
deletingItemId = "456" // Overwrites!
// Now item "123" reappears while "456" is hidden
// Then both reappear when submission completes
The Lessons I Learned
-
fetcher.formDatais not state—it's ephemeral request data - Optimistic UI requires persistence—you need actual state to track what's deleted
- Intents alone don't solve state management—they're just command identifiers
- Filter logic needs to survive across renders—can't depend on transient values
This attempt taught me: The parent needs to OWN the deleted IDs in real state, not borrow them from the fetcher.
Attempt #2: The "Ref Jungle" (❌ Still Broken)
Commit: a8d27c45 - "fixing bugs in delete"
Date: January 21, 2026
Duration: 2 hours of escalating complexity
After Attempt #1 failed, I thought: "The problem is that the success callback is firing multiple times and creating race conditions. I'll use refs to create locks!"
Understanding the Problem I Was Trying to Solve
I noticed in the console:
DELETE SUCCESS! Item: 123
DELETE SUCCESS! Item: 123
DELETE SUCCESS! Item: 123
Toast shown
Toast shown
Toast shown
The handleDeleteSuccess callback was being called 3 times for a single deletion! Why?
Understanding useEffect Dependency Pitfalls
This was happening because of how I structured my useEffect:
// ❌ MY BROKEN CODE
const handleDeleteSuccess = () => {
console.log("DELETE SUCCESS!");
setShowSuccessToast(true);
};
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.success) {
handleDeleteSuccess(); // Called every time this effect runs
}
}, [fetcher.state, fetcher.data, handleDeleteSuccess]);
// ^^^^^^^^^^^ ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^
// Changes Changes NEW FUNCTION EVERY RENDER!
The Problem:
-
handleDeleteSuccessis redefined on every render (new function reference) - It's in the dependency array
- So the effect runs on every render
- Each time, it sees
fetcher.data.successand calls the handler again
The Render Cycle:
Render 1: handleDeleteSuccess = Function@0x001
Effect runs → calls handleDeleteSuccess
Render 2: handleDeleteSuccess = Function@0x002 ← NEW reference!
Dependency changed! Effect runs again → calls handleDeleteSuccess
Render 3: handleDeleteSuccess = Function@0x003 ← NEW reference!
Dependency changed! Effect runs again → calls handleDeleteSuccess
My Solution: The Ref Locks
I thought: "I'll use useRef to create 'locks' that prevent double-execution!"
// ========================================
// ATTEMPT #2 CODE
// ========================================
function ItemsPage() {
const fetcher = useFetcher();
const data = useLoaderData();
// ========= STATE =========
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedItemName, setSelectedItemName] = useState("");
const [selectedItemId, setSelectedItemId] = useState("");
const [showSuccessToast, setShowSuccessToast] = useState(false);
const [showErrorMessage, setShowErrorMessage] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
// ========= THE REF JUNGLE =========
// Ref #1: Store the ID we're currently deleting
const selectedItemIdRef = useRef<string>("");
// Ref #2: Track which ID we've already shown success for
const successHandledRef = useRef<string | null>(null);
// Ref #3: Track which error we've already shown
const dismissedErrorRef = useRef<string | null>(null);
// Ref #4: Store timeout for auto-dismissing errors
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Ref #5: Store timeout for auto-dismissing success toast
const successTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// ========= OPEN DELETE DIALOG =========
const openDeleteDialog = (itemName: string, itemId: string) => {
// Clear any existing timeouts
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current);
errorTimeoutRef.current = null;
}
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = null;
}
// Reset states
setShowErrorMessage(false);
setErrorMessage("");
setShowSuccessToast(false);
dismissedErrorRef.current = null;
// Store the item info
setSelectedItemName(itemName);
setSelectedItemId(itemId);
selectedItemIdRef.current = itemId; // Store in ref too!
setIsDeleteDialogOpen(true);
};
// ========= HANDLE DELETE SUCCESS =========
const handleDeleteSuccess = useCallback(() => {
// 🔒 LOCK #1: Prevent duplicate success handling
if (successHandledRef.current === selectedItemIdRef.current) {
console.log("Already handled success for", selectedItemIdRef.current);
return; // Exit early - we've already processed this
}
console.log("Handling success for", selectedItemIdRef.current);
successHandledRef.current = selectedItemIdRef.current; // Mark as handled
// Clear any errors
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current);
errorTimeoutRef.current = null;
}
setShowErrorMessage(false);
setErrorMessage("");
// Show success toast
setShowSuccessToast(true);
// Auto-dismiss toast after 3 seconds
successTimeoutRef.current = setTimeout(() => {
setShowSuccessToast(false);
successHandledRef.current = null; // Reset the lock
}, 3000);
}, []); // Empty deps - function never changes
// ========= HANDLE DELETE ERROR =========
const handleDeleteError = useCallback((error: string) => {
// 🔒 LOCK #2: Prevent showing same error multiple times
if (dismissedErrorRef.current === error) {
console.log("Error already dismissed:", error);
return;
}
// Clear success toast
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = null;
}
setShowSuccessToast(false);
// Show error
setErrorMessage(error);
setShowErrorMessage(true);
// Auto-dismiss error after 5 seconds
errorTimeoutRef.current = setTimeout(() => {
setShowErrorMessage(false);
setErrorMessage("");
dismissedErrorRef.current = null; // Reset the lock
}, 5000);
}, []);
// ========= MONITOR FETCHER FOR RESULTS =========
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
const response = fetcher.data as DeleteResponse;
if (response.success) {
handleDeleteSuccess();
} else if (response.error) {
handleDeleteError(response.error);
}
}
}, [fetcher.state, fetcher.data, handleDeleteSuccess, handleDeleteError]);
// ... rest of component
}
What I Expected
I thought the refs would act like "mutex locks" in backend programming:
- First call to
handleDeleteSuccesssetssuccessHandledRef = "123" - Subsequent calls see the lock and return early
- After toast auto-dismisses, reset the lock for next deletion
What Actually Happened
Problem 1: Lock Reset Timing
// Deletion 1: Item "123"
successHandledRef.current = "123" // Lock set
setTimeout(() => {
successHandledRef.current = null; // Lock cleared after 3s
}, 3000);
// User immediately deletes Item "456"
if (successHandledRef.current === selectedItemIdRef.current) {
// successHandledRef = "123"
// selectedItemIdRef = "456"
// Not equal! So it runs... but...
}
// Wait, what if the first timeout fires while we're deleting the second?
// Lock gets cleared while we still need it!
Problem 2: Still No Solution for Zombie Rows
All these refs were preventing duplicate success messages, but they did NOTHING to solve the actual zombie row problem! The item was still visible in the table because the DataTable still had no way to know about the deletion.
Problem 3: Code Became Unreadable
Look at that code! 5 different refs tracking different aspects of state. It became:
- Hard to understand what each ref does
- Hard to debug (refs don't show in React DevTools)
- Fragile (race conditions between timeouts and user actions)
- Not solving the actual problem
The Error I Made: Treating Symptoms, Not the Disease
I was using refs as "band-aids" to fix symptoms:
- ✓ Prevented duplicate toast messages
- ✓ Prevented duplicate error alerts
- ✓ Managed timeouts
But I wasn't fixing the disease:
- ❌ Zombie rows still appeared
- ❌ State management still broken
- ❌ Parent-child communication still missing
Understanding Why Refs Were the Wrong Tool
Refs are good for:
- Storing timers (so you can clear them)
- Storing DOM references
- Storing previous values for comparison
- Values that don't affect rendering
Refs are BAD for:
- Managing UI state (use
useState) - Coordinating between components (use props)
- Data that should trigger re-renders
- Trying to "lock" or "synchronize" state updates
Backend Analogy:
It's like I was using database triggers to prevent duplicate API calls, when the real problem was that my API endpoint was being called incorrectly in the first place. I should have fixed the caller, not added defensive logic in the database.
The Lessons I Learned
- Refs don't fix architecture problems—they're a tool, not a solution
- Defensive coding indicates a deeper issue—if you need 5 locks, your design is wrong
- Complexity is a code smell—simple problems should have simple solutions
- Fix the root cause, not the symptoms—zombie rows were the real issue
After this attempt, I realized: I need to rethink the entire state management architecture, not add more band-aids.
Attempt #3: State Ownership & The Infinite Loop (🔧 Partial Success)
Commit: 47748f68 - "fixing load more issues with delete"
Date: January 23, 2026
Duration: 4 hours of breakthrough and frustration
After two failures, I had a realization: The parent needs to OWN the list of deleted IDs in real state.
The Architectural Shift
I decided to fundamentally change the approach:
- ❌ Old: Child (DataTable) owns data, parent tries to influence it
- ✅ New: Parent owns data, parent owns deletions, child just renders
Understanding "State Lifting"
State Lifting means moving state UP the component tree to a common ancestor.
General Pattern:
// ❌ BEFORE: Child owns state
function Parent() {
return <Child />; // Parent has no control
}
function Child() {
const [data, setData] = useState([1, 2, 3]); // Child owns it
return <div>{data.map(n => <span>{n}</span>)}</div>;
}
// ✅ AFTER: Parent owns state
function Parent() {
const [data, setData] = useState([1, 2, 3]); // Parent owns it
return <Child data={data} />; // Parent controls it
}
function Child({ data }) {
// Child is now "dumb" - just displays what parent gives
return <div>{data.map(n => <span>{n}</span>)}</div>;
}
Why Lift State?
- Multiple components need the same data
- Parent needs to modify child's data
- Sibling components need to communicate
- Easier to debug (single source of truth)
My Implementation: The deletedItemIds Set
// ========================================
// FILE: pages/items/index.tsx
// ========================================
function ItemsPage() {
const data = useLoaderData();
const fetcher = useFetcher();
// ========= LIFTED STATE =========
// NEW: Parent owns the list of deleted IDs
const [deletedItemIds, setDeletedItemIds] = useState<Set<string>>(new Set());
// Still using a ref to track current deletion
const selectedItemIdRef = useRef<string>("");
// ========= DELETE SUCCESS HANDLER =========
const handleDeleteSuccess = useCallback(() => {
// Add the deleted ID to our Set
setDeletedItemIds((prev) => {
const newSet = new Set(prev); // Copy the old Set
newSet.add(selectedItemIdRef.current); // Add new ID
return newSet; // Return new Set (triggers re-render)
});
setShowSuccessToast(true);
}, []);
// ========= FILTERING LOGIC =========
// Create filtered version of edges
const visibleItems = useMemo(() => {
return (data.connection?.edges ?? []).filter(edge => {
// If this item's ID is in our deleted set, don't show it
return !deletedItemIds.has(edge?.node?.id ?? "");
});
}, [data.connection?.edges, deletedItemIds]);
// Create a new connection object with filtered edges
const connection = useMemo(() => ({
edges: visibleItems,
pageInfo: data.connection?.pageInfo,
}), [visibleItems, data.connection?.pageInfo]);
// ========= RENDER =========
return (
<Container>
<DataTable
connection={connection} // Pass filtered connection
header={header}
renderRow={renderRow}
fetcher={fetcher}
/>
</Container>
);
}
Why Use a Set Instead of an Array?
I used Set<string> instead of string[]. Here's why:
// ❌ With Array - O(n) lookup
const deletedIds = ["123", "456", "789"];
const isDeleted = deletedIds.includes("456"); // Loops through array
// ✅ With Set - O(1) lookup
const deletedIds = new Set(["123", "456", "789"]);
const isDeleted = deletedIds.has("456"); // Hash table lookup (instant)
// Performance comparison with 100 deleted items:
// Array: 100 comparisons worst case
// Set: 1 hash lookup
Backend Analogy: Set is like a database index—instant lookup vs. full table scan.
Understanding useMemo in This Context
const visibleItems = useMemo(() => {
return (data.connection?.edges ?? []).filter(edge => {
return !deletedItemIds.has(edge?.node?.id ?? "");
});
}, [data.connection?.edges, deletedItemIds]);
// ^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// Recalc when edges Or deletedItemIds
// change changes
Why useMemo here?
Without useMemo, this filter would run on EVERY render:
// ❌ Without useMemo
function Component() {
const visibleItems = data.connection.edges.filter(...); // Runs every render
// Even if data.connection and deletedItemIds haven't changed!
return <Table rows={visibleItems} />;
}
// ✅ With useMemo
function Component() {
const visibleItems = useMemo(() =>
data.connection.edges.filter(...),
[data.connection.edges, deletedItemIds]
); // Only runs when dependencies change
return <Table rows={visibleItems} />;
}
What Worked: The Immediate Win
✅ Instant Removal:
// User clicks delete
handleDeleteSuccess();
// Inside handleDeleteSuccess:
setDeletedItemIds(prev => new Set(prev).add("123"));
// React re-renders:
visibleItems = edges.filter(edge => !deletedItemIds.has(edge.node.id));
// Item "123" is filtered out
// Row disappears immediately!
✅ Persistence Across Load More:
// User has deleted item "123"
deletedItemIds = Set(["123"])
// User clicks "Load More"
// Server returns page 2 with items 21-40
// One of them happens to be item "123" again (shouldn't happen, but if it does)
visibleItems = allEdges.filter(edge => !deletedItemIds.has(edge.node.id));
// Item "123" stays filtered out!
What Still Didn't Work: The Infinite Loop
But then I encountered a NEW problem: the infinite re-render loop.
The Bug in DataTable
Remember, DataTable still had internal state for handling "Load More":
// ========================================
// FILE: components/DataTable.tsx (BUGGY VERSION)
// ========================================
function DataTable({ connection, fetcher }) {
// DataTable STILL owns edges state
const [edges, setEdges] = useState(connection.edges);
// Handle Load More
useEffect(() => {
if (!fetcher.data?.connection) return;
// Append new edges
setEdges(prev => [...prev, ...fetcher.data.connection.edges]);
// ^^^^
// Reading edges here
}, [fetcher.data, edges]); // ❌ edges in dependency array!
// ^^^^^^
return (
<table>
{edges.map(edge => <ItemRow key={edge.node.id} item={edge.node} />)}
</table>
);
}
Understanding the Infinite Loop
Initial Render:
────────────────
edges = [item1, item2, item3]
Effect runs (fetcher.data is null, so returns early)
User Clicks "Load More":
────────────────────────
fetcher.data = { connection: { edges: [item4, item5] } }
Effect runs:
setEdges([item1, item2, item3, item4, item5])
edges changes → Effect deps changed!
────────────────────────────────────
Effect runs AGAIN:
fetcher.data still has { edges: [item4, item5] }
setEdges([item1, item2, item3, item4, item5, item4, item5]) ← DUPLICATES!
edges changes again → Effect deps changed AGAIN!
──────────────────────────────────────────────────
Effect runs AGAIN:
setEdges([..., item4, item5, item4, item5, item4, item5]) ← MORE DUPLICATES!
Infinite loop:
setEdges → edges changes → effect runs → setEdges → ...
The Core Issue:
useEffect(() => {
setEdges([...edges, ...newEdges]); // ❌ Reading `edges`
}, [edges]); // ❌ `edges` as dependency
// This creates a feedback loop:
// edges changes → effect runs → setEdges → edges changes → ...
My Attempted Fix: The Nuclear Option
I tried a desperate hack:
<DataTable
key={visibleItems.length} // ← Force React to destroy and recreate
connection={connection}
/>
What key Does:
When the key prop changes, React completely destroys the old component and creates a brand new one.
// First render:
<DataTable key={20} /> // 20 items visible
// After deletion:
<DataTable key={19} /> // 19 items visible
// React sees different key:
// 1. Unmount old <DataTable key={20}> (destroy it completely)
// 2. Mount new <DataTable key={19}> (fresh new instance)
Why This "Worked":
- Every time visible count changed, React destroyed and recreated DataTable
- New instance = fresh state = no infinite loop
- But TERRIBLE for performance and user experience
The Side Effects:
- Lost scroll position
- Lost any UI state in the table
- Caused visible "flash" as table re-rendered
- Destroyed focus state
- Horrible hack that solved symptoms, not cause
Why It Was "Partial Success"
✅ Wins:
- Deleted items disappeared immediately
- Deletions persisted across Load More
- Single source of truth (parent owns deletedItemIds)
- No more ref jungles
❌ Problems:
- Infinite loop with Load More
- Had to use
keyhack to prevent loop - DataTable still had internal state (wrong!)
- Tight coupling (parent creates filtered connection)
- Not reusable (DataTable expects specific structure)
The Lessons I Learned
- State lifting solves parent-child communication—but both must cooperate
- Reading state you're setting in useEffect = infinite loop—use functional setState
-
keyprop is for lists, not fixing bugs—using it to force remount is a code smell - Child components with internal state are hard to control—presentational pattern is better
- Partial solutions create technical debt—you're just postponing the real fix
This attempt got us 70% there, but the architecture was still fighting itself. The DataTable needed to be completely stateless for this to work properly.
🏆 Chapter 5: The Victory (The Final Solution)
Current Implementation
Date: January 27, 2026
The Moment Everything Clicked
After three failed attempts and hours of debugging, I finally understood the root problem: I was trying to control a stateful child from a stateful parent. Both were fighting for ownership of the data.
The solution? Make the child completely stateless.
Understanding Container/Presentational Pattern
This is a fundamental React pattern that I should have used from the start.
The Pattern Explained
Container Component (Smart):
- Owns all state
- Handles business logic
- Makes API calls
- Processes data
- Manages side effects (useEffect)
- Passes data DOWN to presentational components
Presentational Component (Dumb):
- Receives props only
- No useState
- No useEffect
- No business logic
- Just renders what it's told
- Pure function: same props = same output
General Example:
// ========================================
// ❌ BAD: Both components manage state
// ========================================
function Parent() {
const [filter, setFilter] = useState("");
return <Child onFilterChange={setFilter} />; // Trying to control child
}
function Child({ onFilterChange }) {
const [items, setItems] = useState([...]); // Child owns data
const [filteredItems, setFilteredItems] = useState([...]);
useEffect(() => {
// Child tries to sync with parent - messy!
}, [onFilterChange]);
return <List items={filteredItems} />;
}
// ========================================
// ✅ GOOD: Container/Presentational split
// ========================================
// CONTAINER (Smart)
function Parent() {
const [items, setItems] = useState([...]); // Parent owns data
const [filter, setFilter] = useState(""); // Parent owns filter
const filteredItems = useMemo(() =>
items.filter(item => item.includes(filter)),
[items, filter]
); // Parent does the filtering
return <Child items={filteredItems} />; // Just pass filtered data down
}
// PRESENTATIONAL (Dumb)
function Child({ items }) {
// No state! Just renders what parent gives
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
Benefits:
- Single Source of Truth: Parent owns everything
- Easier to Test: Presentational component is pure (no mocks needed)
- Reusable: Presentational component works with ANY data
- No Sync Issues: No two sources of state to keep in sync
- Debuggable: Just look at parent's state in React DevTools
The Breakthrough: Refactoring DataTable
I completely rewrote DataTable from stateful to stateless.
Before: Stateful DataTable (Problematic)
// ❌ BEFORE: DataTable manages its own state
export function DataTable({ connection, fetcher }) {
// Child owns edges state
const [edges, setEdges] = useState(connection.edges);
// Child handles Load More
useEffect(() => {
if (!fetcher.data?.connection) return;
setEdges(prev => [...prev, ...fetcher.data.connection.edges]);
}, [fetcher.data, edges]); // ← Infinite loop!
return (
<table>
{edges.map(edge => <Row key={edge.node.id} data={edge.node} />)}
{connection.pageInfo?.hasNextPage && (
<LoadMoreButton fetcher={fetcher} />
)}
</table>
);
}
Problems:
- Has its own state (
edges) - Manages side effects (useEffect for Load More)
- Parent can't control what's displayed
- Infinite loop bug
- Not reusable
After: Presentational DataTable (Clean)
// ✅ AFTER: DataTable is completely stateless
export type DataTableProps<T> = {
edges: ConnectionEdge<T>[]; // Parent provides edges
pageInfo?: PageInfo; // Parent provides pageInfo
renderRow: (edge: ConnectionEdge<T>) => ReactElement | null; // Parent controls rendering
fetcher?: FetcherWithComponents<unknown>;
header: ReactElement;
};
export function DataTable<T>({
edges,
pageInfo,
renderRow,
fetcher,
header,
}: DataTableProps<T>) {
// ✅ NO useState
// ✅ NO useEffect
// ✅ NO business logic
// Just process what parent gives us
const rows = useMemo(
() => edges.map(renderRow).filter(Boolean), // Remove nulls
[edges, renderRow]
);
return (
<Table>
<thead>{header}</thead>
<tbody>{rows}</tbody>
{pageInfo?.hasNextPage && (
<tfoot>
<tr>
<td>
<LoadMoreButton fetcher={fetcher} />
</td>
</tr>
</tfoot>
)}
</Table>
);
}
Key Changes:
-
Removed
useState: No internal state -
Removed
useEffect: No side effects -
Added
renderRowprop: Parent controls how each row renders -
Added
edgesprop: Parent passes the data -
Made it generic
<T>: Works with any data type
The Parent: Complete State Ownership
The parent (ItemsPage) now owns EVERYTHING:
// ========================================
// FILE: pages/items/index.tsx
// THE CONTAINER (Smart Component)
// ========================================
function ItemsPage() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
// ========= PARENT OWNS ALL STATE =========
// 1. Owns the edges (accumulated from all loaded pages)
const [edges, setEdges] = useState<ConnectionEdge<Item>[]>(
data.connection?.edges ?? []
);
// 2. Owns the pageInfo (for knowing if there are more pages)
const [pageInfo, setPageInfo] = useState<PageInfo | undefined>(
data.connection?.pageInfo
);
// 3. Owns the list of deleted IDs
const [deletedItemIds, setDeletedItemIds] = useState<Set<string>>(
new Set()
);
// For tracking which item is being deleted
const selectedItemIdRef = useRef<string>("");
// ========= PARENT HANDLES LOAD MORE =========
useEffect(() => {
// Only process when fetcher is done and has data
if (fetcher.state !== "idle") return;
if (!fetcher.data?.connection) return;
// Update pageInfo for next page
setPageInfo(fetcher.data.connection.pageInfo ?? undefined);
// Append new edges using FUNCTIONAL setState
setEdges(prev => [...prev, ...fetcher.data.connection.edges]);
// ^^^^
// ✅ Using prev, not reading edges
// ✅ No infinite loop!
}, [fetcher.data, fetcher.state]);
// ^^^^^^^^^^^^ ^^^^^^^^^^^^^
// ✅ Only these two dependencies
// ✅ edges is NOT in the array
// ========= PARENT HANDLES DELETION =========
const handleDeleteSuccess = useCallback(() => {
// Add deleted ID to our Set
setDeletedItemIds((prev) => {
const newSet = new Set(prev);
newSet.add(selectedItemIdRef.current);
return newSet;
});
setShowSuccessToast(true);
}, []);
// ========= PARENT CONTROLS ROW RENDERING =========
const renderRow = useCallback((edge: ConnectionEdge<Item>) => {
const item = edge?.node;
if (!item) return null;
// ✅ FILTERING LOGIC: Return null for deleted items
if (deletedItemIds.has(item.id)) {
return null; // React will filter this out
}
// Render the actual row
return (
<ItemTableRow
key={item.id}
edge={edge}
canEdit={canEdit}
canDelete={canDelete}
onDelete={() => openDeleteDialog(item.name, item.id)}
/>
);
}, [deletedItemIds, canEdit, canDelete, openDeleteDialog]);
// ^^^^^^^^^^^^^^^
// Only recreate renderRow when deletedItemIds changes
// ========= RENDER =========
return (
<Container>
{showSuccessToast && (
<Toast message="Item deleted successfully" />
)}
{showErrorMessage && (
<Alert message={errorMessage} />
)}
<ItemDeleteDialog
isOpen={isDeleteDialogOpen}
itemName={selectedItemName}
itemId={selectedItemIdRef.current}
onSuccess={handleDeleteSuccess}
onError={handleDeleteError}
/>
<DataTable
edges={edges} // ✅ Parent provides
pageInfo={pageInfo} // ✅ Parent provides
renderRow={renderRow} // ✅ Parent controls filtering
fetcher={fetcher} // For Load More button
header={<TableHeader />}
/>
</Container>
);
}
Why This Works: The Magic of renderRow
The key innovation is the renderRow pattern:
// Parent defines this function
const renderRow = (edge) => {
if (deletedItemIds.has(edge.node.id)) {
return null; // ← Deleted items return null
}
return <ItemTableRow edge={edge} />;
};
// Child (DataTable) uses it
const rows = edges.map(renderRow).filter(Boolean);
// ^^^^^^^^^^^^^^
// Remove all nulls
How It Works:
// Example data flow:
edges = [
{ node: { id: "1", name: "Admin" } },
{ node: { id: "2", name: "Manager" } }, // ← User deleted this
{ node: { id: "3", name: "Viewer" } }
]
deletedItemIds = Set(["2"])
// Step 1: edges.map(renderRow)
[
<ItemTableRow edge={edge1} />, // renderRow returned component
null, // renderRow returned null (deleted!)
<ItemTableRow edge={edge3} /> // renderRow returned component
]
// Step 2: .filter(Boolean)
[
<ItemTableRow edge={edge1} />, // Boolean(component) = true ✓
// Boolean(null) = false ✗ (filtered out)
<ItemTableRow edge={edge3} /> // Boolean(component) = true ✓
]
// Result: Item "2" is gone from UI!
Why filter(Boolean) Works:
Boolean(null) === false // ✗ Filtered out
Boolean(undefined) === false // ✗ Filtered out
Boolean(0) === false // ✗ Filtered out
Boolean("") === false // ✗ Filtered out
Boolean(<Component />) === true // ✓ Kept
Boolean({}) === true // ✓ Kept
Understanding Functional setState (No More Infinite Loops)
This was the KEY to fixing the infinite loop:
// ❌ BROKEN: Reading state in setState
useEffect(() => {
setEdges([...edges, ...newEdges]); // Reading `edges`
}, [edges]); // `edges` in dependencies
// → edges changes → effect runs → setEdges → edges changes → infinite loop!
// ✅ FIXED: Functional setState
useEffect(() => {
setEdges(prev => [...prev, ...newEdges]); // Using `prev` parameter
}, [fetcher.data]); // `edges` NOT in dependencies
// → fetcher.data changes → effect runs → setEdges → done!
How Functional setState Works:
// React guarantees `prev` is the LATEST value
setEdges(prev => {
// prev = current edges at the moment of execution
// Even if multiple setEdges are queued, each gets correct `prev`
return [...prev, ...newEdges];
});
// Example with multiple updates:
setEdges(prev => [...prev, 1]); // prev = [] → [1]
setEdges(prev => [...prev, 2]); // prev = [1] → [1, 2]
setEdges(prev => [...prev, 3]); // prev = [1, 2] → [1, 2, 3]
// All queued updates execute correctly!
Backend Analogy:
-- ❌ Bad: Read then Write (race condition)
current_count = SELECT count FROM table;
UPDATE table SET count = current_count + 1;
-- ✅ Good: Atomic operation
UPDATE table SET count = count + 1;
The Complete Data Flow
Let me show you the ENTIRE flow from delete click to UI update:
USER ACTION: Clicks delete button on "Manager" item (id: "123")
────────────────────────────────────────────────────────────────
1. openDeleteDialog("Manager", "123")
└─ selectedItemIdRef.current = "123"
└─ setIsDeleteDialogOpen(true)
2. Dialog appears, user clicks "Delete"
└─ fetcher.submit({}, { method: "delete", action: "/items/123/delete" })
3. SERVER processes deletion
└─ DELETE_ITEM mutation executes
└─ Returns: { success: true }
4. fetcher.data = { success: true }
└─ fetcher.state = "idle"
5. useEffect detects fetcher.data changed
└─ handleDeleteSuccess() called
6. Inside handleDeleteSuccess:
setDeletedItemIds(prev => {
const newSet = new Set(prev); // Copy old Set: Set([])
newSet.add("123"); // Add deleted ID: Set(["123"])
return newSet; // Return new Set
});
7. deletedItemIds changes → Parent re-renders
8. renderRow function executes for each edge:
edge1 (id: "1", name: "Admin"):
├─ deletedItemIds.has("1") ? NO
└─ return <ItemTableRow edge={edge1} />
edge2 (id: "123", name: "Manager"): ← THE DELETED ONE
├─ deletedItemIds.has("123") ? YES! ✓
└─ return null ← Filtered out!
edge3 (id: "3", name: "Viewer"):
├─ deletedItemIds.has("3") ? NO
└─ return <ItemTableRow edge={edge3} />
9. DataTable receives:
edges = [edge1, edge2, edge3] // All 3 edges
renderRow = (edge) => ... // Function that returns null for edge2
rows = edges.map(renderRow) // [<Row1 />, null, <Row3 />]
.filter(Boolean) // [<Row1 />, <Row3 />]
10. UI renders:
┌────────────────────┐
│ Admin [View][Edit][Delete] │
│ Viewer [View][Edit][Delete] │ ← Manager is GONE!
└────────────────────┘
TOTAL TIME: < 10ms (instant to user's eye)
Why This Solution is Perfect
1. Instant Removal
// The moment setDeletedItemIds completes:
// → React schedules re-render
// → renderRow filters out deleted ID
// → DataTable receives filtered rows
// → DOM updates
// User sees row disappear (< 16ms for 60fps)
No waiting for:
- ✗ Server confirmation (we already have it)
- ✗ Re-fetching data
- ✗ Cache invalidation
- ✗ Page reload
2. Load More Integration
// Scenario: User deletes item "123", then clicks Load More
// Current state:
edges = [item1, item2, item3] // Page 1
deletedItemIds = Set(["123"])
// User clicks Load More:
fetcher.submit() → Server returns page 2: [item4, item5, item6]
// useEffect runs:
setEdges(prev => [...prev, item4, item5, item6])
// Now edges = [item1, item2, item3, item4, item5, item6]
// renderRow still filters:
rows = edges
.map(edge => deletedItemIds.has(edge.id) ? null : <Row />)
.filter(Boolean)
// Even if item "123" was in page 2, it stays filtered!
3. No State Sync Issues
// Old problem: Parent and child both had state
// Parent: deletedIds = ["123"]
// Child: edges = [item1, item2, item3] ← Includes item "123"!
// → Out of sync! Zombie row appears
// New solution: Only parent has state
// Parent: edges = [item1, item2, item3]
// deletedIds = ["123"]
// renderRow = filters using deletedIds
// Child: Just renders what parent gives
// → Always in sync!
4. Easy to Test
// Testing DataTable (presentational):
test('renders rows from edges prop', () => {
const edges = [edge1, edge2, edge3];
const renderRow = (edge) => <div>{edge.node.name}</div>;
render(<DataTable edges={edges} renderRow={renderRow} ... />);
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
// No mocking fetchers, no mocking API calls, no complex setup!
// Just: props in → UI out
5. Completely Reusable
The same DataTable can now be used for ANY paginated list:
// For Items:
<DataTable
edges={itemEdges}
renderRow={(edge) => <ItemRow item={edge.node} />}
/>
// For Users:
<DataTable
edges={userEdges}
renderRow={(edge) => <UserRow user={edge.node} />}
/>
// For Permissions:
<DataTable
edges={permissionEdges}
renderRow={(edge) => <PermissionRow permission={edge.node} />}
/>
No item-specific logic in DataTable!
Performance Characteristics
// Deletion operation complexity:
// Adding to Set:
deletedItemIds.add("123") // O(1) - hash table insert
// Checking if deleted:
deletedItemIds.has("123") // O(1) - hash table lookup
// Filtering edges:
edges.map(renderRow) // O(n) - where n = number of edges
.filter(Boolean) // O(n) - linear pass
// Total: O(n) where n = visible edges
// For 100 items: ~1-2ms
// For 1000 items: ~10-20ms (still imperceptible)
Compare to alternatives:
// Re-fetching from server:
// Network latency: 100-500ms
// Server query: 10-50ms
// Total: 110-550ms (noticeable delay)
// Our local filtering:
// Computation: 1-20ms (instant)
The Final Architecture Diagram
┌─────────────────────────────────────────────────────────┐
│ ItemsPage (Container) │
│ │
│ State: │
│ ├─ edges: ConnectionEdge<Item>[] │
│ ├─ pageInfo: PageInfo │
│ ├─ deletedItemIds: Set<string> │
│ └─ showSuccessToast: boolean │
│ │
│ Logic: │
│ ├─ handleDeleteSuccess() │
│ ├─ handleLoadMore() (via useEffect) │
│ └─ renderRow() (filtering logic) │
│ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ DeleteDialog │ │ DataTable │ │
│ │ (Smart) │ │ (Presentational│ │
│ │ │ │ │ │
│ │ - Manages │ │ - No state │ │
│ │ fetcher │ │ - No effects │ │
│ │ - Calls │ │ - Just renders │ │
│ │ onSuccess │ │ - Pure │ │
│ └────────────────┘ └────────────────┘ │
│ ▲ ▲ │
│ │ │ │
│ props: { itemId, props: { edges, │
│ onSuccess } pageInfo, │
│ renderRow } │
└─────────────────────────────────────────────────────────┘
Data flows DOWN (props)
Events flow UP (callbacks)
// OLD (Broken):
useEffect(() => {
setEdges([...edges, ...newEdges]);
}, [edges]); // ❌ edges changes → setEdges → edges changes → infinite loop
// NEW (Fixed):
useEffect(() => {
setEdges((prev) => [...prev, ...newEdges]); // ✅ Functional setState
}, [fetcher.data]); // ✅ No edges in deps
4. Single Source of Truth
- Parent = Container = Owns ALL state
- DataTable = Presentational = Just renders what parent gives
- Clear separation of concerns
- Easy to test, debug, and maintain
5. Reusability
- DataTable is now 100% generic
- No item-specific logic
- Can be reused for any paginated list (users, permissions, etc.)
- Parent controls filtering logic via
renderRow
Key Learnings
1. State Ownership Matters
❌ Anti-Pattern:
// Child owns state → Parent can't control child
function Child({ initialData }) {
const [data, setData] = useState(initialData);
// Parent has NO way to update this
}
✅ Best Practice:
// Parent owns state → Parent has full control
function Parent() {
const [data, setData] = useState(initialData);
return <Child data={data} />; // Child just renders
}
2. Functional setState Prevents Loops
❌ Dangerous:
useEffect(() => {
setData([...data, ...newData]); // Reading data
}, [data]); // ❌ data in deps → infinite loop
✅ Safe:
useEffect(() => {
setData((prev) => [...prev, ...newData]); // Not reading data
}, [newData]); // ✅ data NOT in deps → no loop
3. Optimistic UI vs. Confirmed Updates
Optimistic UI (update before server confirms):
- Best for: Likes, favorites, simple toggles
- Fast perceived performance
- Risk: Rollback on failure
Confirmed Updates (update after server confirms):
- Best for: Deletions, critical operations
- Safer, no rollback needed
- Our choice: Wait for server, then filter locally
4. Container/Presentational Pattern
Container (Smart Component):
- Manages state, effects, business logic
- Handles API calls, data transformations
- Owns the "how it works"
Presentational (Dumb Component):
- Receives props, renders UI
- Pure functions, no side effects
- Owns the "how it looks"
Benefits:
- Easier testing (presentational has no dependencies)
- Reusability (presentational works anywhere)
- Maintainability (clear responsibilities)
Performance Considerations
Why Local Filtering Is Efficient
const renderRow = (edge) => {
if (deletedItemIds.has(edge?.node?.id)) return null; // O(1) Set lookup
return <ItemTableRow edge={edge} />;
};
const rows = edges.map(renderRow).filter(Boolean);
-
Set lookup:
O(1)- Instant hash lookup -
Map operation:
O(n)- Linear through edges -
Filter operation:
O(n)- Remove nulls
Total: O(n) where n = number of edges
Why not re-fetch?
- Network request: 100-500ms latency
- Our filtering: < 1ms for 100 rows
- Plus: Works offline, no loading state needed
Testing Evolution
Initial Tests (Optimistic UI)
// Expected optimistic removal
cy.get('[data-testid="item-row"]').should("have.length", 19); // Before server response
Current Tests (Confirmed Updates)
// Wait for server confirmation
cy.wait("@deleteItem");
cy.get('[data-testid="delete-success-toast"]').should("be.visible");
// Then verify removal
cy.get('[data-testid="item-row"]').should("have.length", 19);
26 comprehensive test cases covering:
- Dialog interaction
- Success/error flows
- Pagination integration
- Load More after deletion
- Admin item protection
- Toast auto-dismiss (5s accessibility)
- Page refresh persistence
💡 Chapter 6: The Lessons (Key Takeaways)
This journey demonstrates fundamental React concepts that every developer should understand when building interactive UIs.
Lesson 1: State Ownership is Everything
Common Mistake:
Many developers try to "tell" child components what to do, treating them like independent services receiving commands.
// ❌ Imperative Thinking:
// "Send a message to the Table telling it to delete row 123"
<DataTable deletingId="123" />
// The table receives the "command" but may ignore it
// because it has its own internal state
The React Way:
React components don't receive "commands." They receive data (props) and render that data.
// ✅ The React Way:
// "Give the Table the list of rows to display"
<DataTable edges={filteredEdges} />
// Parent does the filtering, child just displays
Backend Analogy:
❌ Bad: Microservice with internal database
API → Service A (has own DB) → Service B (has own DB)
Problem: Two sources of truth, sync issues
✅ Good: Shared database, stateless services
API → Service A (stateless) → Database ← Service B (stateless)
Solution: One source of truth
The Rule:
In React, data flows DOWN (via props) and events flow UP (via callbacks). Never try to make data flow sideways or let children own critical state.
Lesson 2: useEffect Dependencies Are Not Optional
Common Mistakes:
// Mistake #1: Missing dependency
useEffect(() => {
fetchData(userId); // Using userId
}, []); // ❌ userId not in array - uses stale value
// Mistake #2: Wrong dependency
useEffect(() => {
setData([...data, newItem]); // Reading data
}, [data]); // ❌ Infinite loop!
// Mistake #3: Function dependency
useEffect(() => {
handleUpdate();
}, [handleUpdate]); // ❌ Function recreated every render
The Correct Patterns:
// ✅ Pattern 1: Include all used values
useEffect(() => {
fetchData(userId);
}, [userId]); // userId changes → fetch again
// ✅ Pattern 2: Functional setState
useEffect(() => {
setData(prev => [...prev, newItem]); // Not reading data
}, [newItem]); // Only newItem in deps
// ✅ Pattern 3: useCallback for functions
const handleUpdate = useCallback(() => {
// logic
}, [deps]); // Stable function reference
useEffect(() => {
handleUpdate();
}, [handleUpdate]); // handleUpdate only changes when deps change
Mental Model:
Think of useEffect like a database trigger:
-- This trigger fires when user_id changes
CREATE TRIGGER on_user_change
AFTER UPDATE OF user_id ON users
FOR EACH ROW EXECUTE FUNCTION fetch_user_data();
-- Your dependencies = what columns trigger the effect
-- Your effect body = the stored procedure that runs
The Rule:
If you use it inside useEffect, it goes in the dependency array. No exceptions. Use ESLint's exhaustive-deps rule.
Lesson 3: Refs vs State - Know When to Use Which
Understanding the Difference:
| Aspect | useState | useRef |
|---|---|---|
| Triggers re-render? | ✅ Yes | ❌ No |
| Survives re-renders? | ✅ Yes | ✅ Yes |
| Shows in React DevTools? | ✅ Yes | ❌ No |
| Good for UI data? | ✅ Yes | ❌ No |
| Good for timers/DOM refs? | ❌ No | ✅ Yes |
Use useState for:
- Data that affects what the user sees
- Form inputs, toggles, selected items
- Lists, counts, status flags
- Anything that should trigger UI updates
Use useRef for:
- Timer IDs (setTimeout, setInterval)
- DOM element references
- Previous values for comparison
- Tracking if something was already processed
- Any value that doesn't affect rendering
Example:
function Component() {
// ✅ State: User sees this count
const [count, setCount] = useState(0);
// ✅ Ref: User doesn't see this, it's just internal tracking
const renderCountRef = useRef(0);
useEffect(() => {
renderCountRef.current += 1;
console.log(`Rendered ${renderCountRef.current} times`);
});
return (
<div>
<p>Count: {count}</p> {/* Shows in UI */}
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
The Rule:
If it affects what the user sees, use useState. If it's just internal bookkeeping, use useRef.
Lesson 4: Container/Presentational Pattern
The Epiphany:
This pattern is like the Repository Pattern in backend:
Backend (Repository Pattern):
├─ Controller (handles HTTP, delegates)
├─ Service (business logic)
└─ Repository (data access, no logic)
Frontend (Container/Presentational):
├─ Page/Route (handles state, effects)
├─ Container (business logic)
└─ Component (presentation, no logic)
The Split:
// CONTAINER (Smart) - Like a Service Layer
function UserListContainer() {
// Has state
const [users, setUsers] = useState([]);
const [filter, setFilter] = useState("");
// Has effects
useEffect(() => {
fetchUsers().then(setUsers);
}, []);
// Has business logic
const filteredUsers = users.filter(u => u.name.includes(filter));
// Delegates rendering
return <UserListPresentation users={filteredUsers} />;
}
// PRESENTATIONAL (Dumb) - Like a View/Template
function UserListPresentation({ users }) {
// No state, no effects, no logic
// Just renders what it's given
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Benefits:
- Testing: Test presentation with simple props, test container with mocked services
- Reusability: Same presentational component works with different data sources
- Maintainability: Clear separation - logic in one place, UI in another
- Performance: Presentational components can be memoized easily
The Rule:
If a component needs useState or useEffect, it's a Container. If it only receives props and renders, it's Presentational.
Lesson 5: The Power of Functional setState
What I Didn't Understand:
// I thought these were the same:
setCount(count + 1); // ❌
setCount(prev => prev + 1); // ✅
// They're not! Here's why:
The Difference:
// Scenario: Rapid clicks (3 clicks within 1ms)
// ❌ Regular setState:
onClick={() => setCount(count + 1)}
Click 1: count = 0, sets to 0 + 1 = 1
Click 2: count = 0 (still!), sets to 0 + 1 = 1 // Lost update!
Click 3: count = 0 (still!), sets to 0 + 1 = 1 // Lost update!
Result: count = 1 (should be 3!)
// ✅ Functional setState:
onClick={() => setCount(prev => prev + 1)}
Click 1: prev = 0, sets to 0 + 1 = 1
Click 2: prev = 1, sets to 1 + 1 = 2 // Correct!
Click 3: prev = 2, sets to 2 + 1 = 3 // Correct!
Result: count = 3 ✓
When It Matters:
// useEffect appending to arrays:
useEffect(() => {
setEdges([...edges, ...newEdges]); // ❌ Reads stale edges
}, [fetcher.data]); // edges NOT in deps
// Fix:
useEffect(() => {
setEdges(prev => [...prev, ...newEdges]); // ✅ Uses latest
}, [fetcher.data]);
The Rule:
Always use functional setState when: (1) The new value depends on the old value, (2) You're calling setState multiple times, or (3) setState is in useEffect.
Lesson 6: Understanding React's Rendering
What Triggers a Re-Render:
// These trigger re-renders:
setState(newValue) // ✓
parent re-renders // ✓ (even if props unchanged)
context value changes // ✓
// These DON'T trigger re-renders:
ref.current = newValue // ✗
props change (same object) // ✗ (React uses Object.is)
local variable changes // ✗
Example:
function Parent() {
const [count, setCount] = useState(0);
return <Child data={{ count }} />; // ← Creates NEW object every render!
}
function Child({ data }) {
// Child re-renders every time Parent renders
// Even if count hasn't changed!
// Because { count: 1 } !== { count: 1 } (different object refs)
}
// Fix with useMemo:
function Parent() {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ count }), [count]); // Same object if count unchanged
return <Child data={data} />;
}
// Or better: Don't pass objects, pass primitives
function Parent() {
const [count, setCount] = useState(0);
return <Child count={count} />; // Primitive value
}
The Rule:
React compares props with Object.is(). Primitives compare by value, objects/arrays/functions compare by reference.
Lesson 7: Debugging Strategies
Tools I Learned to Use:
-
React DevTools
- Components tab: See props, state, hooks
- Profiler tab: Find slow renders
- Highlight updates: Visual feedback when components re-render
Console Logging
useEffect(() => {
console.log('Effect ran!', { fetcher });
return () => console.log('Effect cleanup!');
}, [fetcher]);
- useEffect Dependency Debugger
useEffect(() => {
console.log('deps changed:', {
'fetcher.data': fetcher.data,
'fetcher.state': fetcher.state,
});
}, [fetcher.data, fetcher.state]);
- Why Did You Render
import { whyDidYouUpdate } from '@welldone-software/why-did-you-render';
whyDidYouUpdate(React, {
trackAllPureComponents: true,
});
The Rule:
When debugging React, check: (1) What state changed? (2) What effect ran? (3) What components re-rendered? (4) What props changed?
Lesson 8: Common Pitfalls for Backend Engineers
Pitfall #1: Treating Components Like API Endpoints
// ❌ Backend thinking:
// "Send a DELETE request to the Table component"
<Table action="delete" id="123" />
// ✅ Frontend thinking:
// "Give Table the data to display"
<Table rows={filteredRows} />
Pitfall #2: Expecting Immediate State Updates
// ❌ Doesn't work:
setCount(1);
console.log(count); // Still 0!
// ✅ Works:
setCount(1);
// ... wait for re-render ...
// Now count is 1
Pitfall #3: Mutating State Directly
// ❌ Mutating (BAD):
const newEdges = edges;
newEdges.push(newItem);
setEdges(newEdges); // React won't detect the change!
// ✅ Creating new array (GOOD):
setEdges([...edges, newItem]); // New array reference
Pitfall #4: Not Understanding Closures
// ❌ Stale closure:
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // `count` is always 0!
}, 1000);
return () => clearInterval(interval);
}, []); // Empty deps = count never updates
// ✅ Fresh value:
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1); // Uses latest value
}, 1000);
return () => clearInterval(interval);
}, []);
Pitfall #5: Over-Engineering with Refs
// ❌ Using refs for everything:
const dataRef = useRef([]);
const filterRef = useRef("");
const loadingRef = useRef(false);
// ✅ Use state for UI data:
const [data, setData] = useState([]);
const [filter, setFilter] = useState("");
const [loading, setLoading] = useState(false);
The Most Important Lessons
The three most critical principles for React state management:
"Lift state to the parent"
When in doubt, move state up. Parent controls, child displays."Use functional setState in effects"
setState(prev => newValue)prevents 90% of bugs."Make children dumb"
Components with no state are easy to understand, test, and reuse.
Core Understanding:
React challenges aren't about complexity—they're about understanding the paradigm shift from imperative to declarative programming.
The zombie row bug wasn't caused by React being "weird." It was caused by fighting React's patterns instead of embracing them.
Once you understand that React is declarative ("here's what the UI should look like") rather than imperative ("do this, then do that"), everything becomes clear.
Appendix: Quick Reference Guide
When to Use Each Hook
| Hook | Use When | Don't Use When |
|---|---|---|
useState |
Data affects UI | Internal tracking only |
useEffect |
Side effects (API, timers, subscriptions) | Calculating derived data |
useRef |
Timers, DOM refs, previous values | UI state |
useMemo |
Expensive calculations | Simple operations |
useCallback |
Passing callbacks to optimized children | Passing to unoptimized children |
useReducer |
Complex state logic, multiple related states | Simple toggles/counters |
Common Patterns Cheat Sheet
Functional setState:
setCount(prev => prev + 1)
setEdges(prev => [...prev, ...newEdges])
setObj(prev => ({ ...prev, key: newValue }))
Effect with cleanup:
useEffect(() => {
const timer = setTimeout(...);
return () => clearTimeout(timer);
}, [deps]);
Conditional effect:
useEffect(() => {
if (!shouldRun) return;
doSomething();
}, [shouldRun, deps]);
Debounced effect:
useEffect(() => {
const timer = setTimeout(() => {
search(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
Filtering with renderRow:
const renderRow = (item) => {
if (shouldHide(item)) return null;
return <Row data={item} />;
};
const rows = items.map(renderRow).filter(Boolean);
Debugging Checklist
When something doesn't work:
- [ ] Check React DevTools for current state/props
- [ ] Add console.logs in render and effects
- [ ] Verify useEffect dependencies are correct
- [ ] Check if state updates are using functional setState
- [ ] Confirm you're not mutating state directly
- [ ] Look for stale closures in effects/callbacks
- [ ] Check if components are keyed properly in lists
- [ ] Verify parent is passing correct props
- [ ] Use React DevTools Profiler for performance issues
🎯 Wrapping Up
This journey from zombie rows to a clean solution demonstrates that React bugs are often architecture problems, not coding problems.
The three failed attempts weren't failures—they were learning experiences that led to understanding:
- ✅ State ownership matters - Parent controls, child renders
- ✅ Functional setState prevents bugs -
setState(prev => ...)is your friend - ✅ Container/Presentational pattern works - Separate logic from presentation
- ✅ React is declarative - Tell it what to show, not what to do
- ✅ useEffect dependencies are critical - No shortcuts allowed
These principles apply far beyond delete operations—they're fundamental to building maintainable React applications.
What's Next?
If you found this helpful:
- 💬 Comment below with your own "zombie row" stories
- ❤️ Save this article for future reference
- 🔗 Share it with teammates struggling with React state
- 🐦 Follow me for more deep-dives into frontend patterns
Quick Reference Card
The Golden Rules:
- Parent owns state, child renders
- Use
setState(prev => ...)in effects - Lift state when child can't control it
- Keep components simple and focused
- Separate business logic from presentation
- Never mutate state directly
- Include ALL dependencies in useEffect
- Use refs for non-UI data only
Top comments (0)