DEV Community

ICraftCode
ICraftCode

Posted on

Solving the Zombie Row Problem: A Deep Dive into React State Management

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

  1. The Problem - Understanding the "Zombie Row" bug
  2. The Infrastructure - GraphQL, Remix, and React architecture
  3. The Toolbox - React Hooks explained from first principles
  4. The Battlefield - Three failed attempts (and why)
  5. The Victory - The Container/Presentational solution
  6. The Lessons - Key takeaways and best practices

🧟 Chapter 1: The Problem (The "Zombie Row")

The Backend Perspective

As a backend engineer, DELETE is straightforward:

  1. Client sends DELETE /api/items/123
  2. Server removes record from database
  3. Server returns 200 OK
  4. 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:

  1. User clicks the 🗑️ (delete icon) on "Item B"
  2. Confirmation dialog appears: "Delete Item B?"
  3. User clicks "Delete"
  4. Network request fires: DELETE /api/items/456
  5. Server responds: { "success": true }
  6. 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} />;
}
Enter fullscreen mode Exit fullscreen mode

The Core Problem:

  1. Child Owns the State: DataTable has its own useState(connection.edges) - it made a private copy of the data when it first rendered
  2. 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
  3. Props Don't Re-trigger State: Passing new props doesn't automatically reset a child's useState value
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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="
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Fetcher States:

  1. idle: Nothing happening, ready for new request
  2. submitting: Request is being sent to server
  3. loading: Server responded, Remix is processing
  4. idle (again): Done, fetcher.data contains 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
Enter fullscreen mode Exit fullscreen mode

The Flow:

  1. User clicks DeleteIcon in an ItemRow
  2. ItemsPage shows DeleteDialog
  3. User confirms → Dialog submits via fetcher
  4. Action runs on server → returns success
  5. 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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 → ...
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Common Uses:

  1. Timers: const timeoutRef = useRef(null); timeoutRef.current = setTimeout(...)
  2. DOM References: const inputRef = useRef(); <input ref={inputRef} />
  3. Previous Values: const prevPropsRef = useRef(); prevPropsRef.current = props
  4. 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

🪝 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" }
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

Why Use Intents?

  1. Single action endpoint handles multiple operations
  2. Cleaner routing (one route vs. many)
  3. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Principles:

  • Parent owns the isOpen state
  • Dialog is controlled (doesn't manage its own visibility)
  • onClose callback 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']}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Principles:

  • Parent owns the data
  • Table is presentational (just renders what it's given)
  • renderRow gives 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
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

What I Expected to Happen

  1. User clicks delete → Dialog submits with { itemId: "123", intent: "delete-item" }
  2. Immediately, fetcher.formData contains this data
  3. Parent reads deletingItemId = "123"
  4. DataTable filters out item "123"
  5. Row disappears instantly (optimistic!)
  6. Server processes deletion
  7. 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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Additional Problems I Encountered

Problem 1: The "Flicker"

The row would:

  1. Disappear (when formData exists)
  2. Reappear for ~200ms (when formData clears but before success logic runs)
  3. 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]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Lessons I Learned

  1. fetcher.formData is not state—it's ephemeral request data
  2. Optimistic UI requires persistence—you need actual state to track what's deleted
  3. Intents alone don't solve state management—they're just command identifiers
  4. 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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

The Problem:

  1. handleDeleteSuccess is redefined on every render (new function reference)
  2. It's in the dependency array
  3. So the effect runs on every render
  4. Each time, it sees fetcher.data.success and 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

What I Expected

I thought the refs would act like "mutex locks" in backend programming:

  1. First call to handleDeleteSuccess sets successHandledRef = "123"
  2. Subsequent calls see the lock and return early
  3. 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!
Enter fullscreen mode Exit fullscreen mode

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

  1. Refs don't fix architecture problems—they're a tool, not a solution
  2. Defensive coding indicates a deeper issue—if you need 5 locks, your design is wrong
  3. Complexity is a code smell—simple problems should have simple solutions
  4. 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:

  1. Old: Child (DataTable) owns data, parent tries to influence it
  2. 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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 → ...
Enter fullscreen mode Exit fullscreen mode

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 → ...
Enter fullscreen mode Exit fullscreen mode

My Attempted Fix: The Nuclear Option

I tried a desperate hack:

<DataTable
  key={visibleItems.length} // ← Force React to destroy and recreate
  connection={connection}
/>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 key hack 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

  1. State lifting solves parent-child communication—but both must cooperate
  2. Reading state you're setting in useEffect = infinite loop—use functional setState
  3. key prop is for lists, not fixing bugs—using it to force remount is a code smell
  4. Child components with internal state are hard to control—presentational pattern is better
  5. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  1. Single Source of Truth: Parent owns everything
  2. Easier to Test: Presentational component is pure (no mocks needed)
  3. Reusable: Presentational component works with ANY data
  4. No Sync Issues: No two sources of state to keep in sync
  5. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Changes:

  1. Removed useState: No internal state
  2. Removed useEffect: No side effects
  3. Added renderRow prop: Parent controls how each row renders
  4. Added edges prop: Parent passes the data
  5. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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} />}
/>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }       │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Best Practice:

// Parent owns state → Parent has full control
function Parent() {
  const [data, setData] = useState(initialData);
  return <Child data={data} />; // Child just renders
}
Enter fullscreen mode Exit fullscreen mode

2. Functional setState Prevents Loops

Dangerous:

useEffect(() => {
  setData([...data, ...newData]); // Reading data
}, [data]); // ❌ data in deps → infinite loop
Enter fullscreen mode Exit fullscreen mode

Safe:

useEffect(() => {
  setData((prev) => [...prev, ...newData]); // Not reading data
}, [newData]); // ✅ data NOT in deps → no loop
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  1. Testing: Test presentation with simple props, test container with mocked services
  2. Reusability: Same presentational component works with different data sources
  3. Maintainability: Clear separation - logic in one place, UI in another
  4. 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:
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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      // ✗
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. React DevTools

    • Components tab: See props, state, hooks
    • Profiler tab: Find slow renders
    • Highlight updates: Visual feedback when components re-render
  2. Console Logging

   useEffect(() => {
     console.log('Effect ran!', { fetcher });
     return () => console.log('Effect cleanup!');
   }, [fetcher]);
Enter fullscreen mode Exit fullscreen mode
  1. useEffect Dependency Debugger
   useEffect(() => {
     console.log('deps changed:', {
       'fetcher.data': fetcher.data,
       'fetcher.state': fetcher.state,
     });
   }, [fetcher.data, fetcher.state]);
Enter fullscreen mode Exit fullscreen mode
  1. Why Did You Render
   import { whyDidYouUpdate } from '@welldone-software/why-did-you-render';

   whyDidYouUpdate(React, {
     trackAllPureComponents: true,
   });
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

The Most Important Lessons

The three most critical principles for React state management:

  1. "Lift state to the parent"
    When in doubt, move state up. Parent controls, child displays.

  2. "Use functional setState in effects"
    setState(prev => newValue) prevents 90% of bugs.

  3. "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 }))
Enter fullscreen mode Exit fullscreen mode

Effect with cleanup:

useEffect(() => {
  const timer = setTimeout(...);
  return () => clearTimeout(timer);
}, [deps]);
Enter fullscreen mode Exit fullscreen mode

Conditional effect:

useEffect(() => {
  if (!shouldRun) return;
  doSomething();
}, [shouldRun, deps]);
Enter fullscreen mode Exit fullscreen mode

Debounced effect:

useEffect(() => {
  const timer = setTimeout(() => {
    search(query);
  }, 500);
  return () => clearTimeout(timer);
}, [query]);
Enter fullscreen mode Exit fullscreen mode

Filtering with renderRow:

const renderRow = (item) => {
  if (shouldHide(item)) return null;
  return <Row data={item} />;
};

const rows = items.map(renderRow).filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Parent owns state, child renders
  2. Use setState(prev => ...) in effects
  3. Lift state when child can't control it
  4. Keep components simple and focused
  5. Separate business logic from presentation
  6. Never mutate state directly
  7. Include ALL dependencies in useEffect
  8. Use refs for non-UI data only

Top comments (0)