How to use useMemo with dynamic per-record paths in TypeScript React dropdown?

Summary

The core problem is a misunderstanding of React’s rendering lifecycle: useMemo executes at the component level and cannot access loop-scoped variables. The developer attempted to use a single useMemo to generate a dynamic action list for every row, but the array contains template literals that depend on a d.id variable which only exists within the .map() callback.

While the provided useCallback solution technically works, it creates a new array instance for every row on every render, defeating the purpose of optimization. The correct solution is to memoize the static configuration (icons and labels) and generate the dynamic paths inside the map loop, or to extract the row into a memoized child component.

Root Cause

The failure stems from two distinct issues:

  • Scope Violation: useMemo executes during the render phase before the data.records.map() iteration begins. Therefore, the variable d (representing the current row) is out of scope and undefined inside the useMemo hook.
  • Type Safety vs. Dynamic Composition: The IRoutePath is a strict union type. A template literal like `/planning/design/item/${d.id}` evaluates to string, which is not assignable to IRoutePath without a type assertion. You cannot generate a literal type at runtime.
  • Re-render Cost: The current useCallback approach returns a new array reference for every single row. If CDropdown uses React.memo, this defeats the memoization because the list prop is always “new.”

Why This Happens in Real Systems

This scenario is extremely common in legacy migration or rapid development environments where TypeScript strictness is high but React patterns are not fully leveraged.

  • Over-optimization: Developers often apply useMemo/useCallback universally without profiling, leading to attempts to optimize immutable operations (like mapping).
  • Confusion of “Definition” vs. “Execution”: Developers often treat hooks as “functions that run when the component renders” rather than “dependency-gated logic that runs only when inputs change.”
  • Strict Type Constraints: When dealing with auto-generated types (like from OpenAPI or route definitions), developers often feel forced to use as Type assertions to bridge the gap between runtime strings and static type definitions.

Real-World Impact

  • Performance Degradation: If the table is large (e.g., 100+ rows), the getActionList(d) approach creates 100+ new array objects and 100+ new object literals per render. This puts pressure on the Garbage Collector and forces child components to re-render unnecessarily.
  • Developer Experience (DX): The solution requires as IRoutePath, which reduces trust in the type system. If the route generator changes, the code compiles but fails at runtime or usage.
  • Maintenance Debt: The “Current Solution” mixes data creation (the array of actions) with presentation. If a new action is added, it must be updated in the map loop logic, increasing cognitive load.

Example or Code

import React, { useMemo, useCallback } from 'react';
import type { IRoutePath } from './routes';

// 1. Define the static configuration outside the render or memoize it
// This contains everything that does NOT change per row.
const staticActionsConfig = [
  {
    id: 'edit',
    // path is intentionally missing here because it's dynamic
  },
  {
    id: 'delete',
  },
];

// 2. Define a helper to generate the typed path
// This keeps the "template literal" logic isolated.
const getPathForRecord = (id: number): IRoutePath => {
  // Because we are returning a specific string literal, 
  // TypeScript knows this matches the union if the literal matches.
  // In strict mode, you might still need 'as' if the union doesn't contain 
  // this EXACT pattern, but usually, it's safer to validate.
  const path = `/planning/design/item/${id}` as unknown as IRoutePath; 
  return path;
};

export function DesignList() {
  // ... hooks and data ...

  // 3. Memoize the structure, but delay dynamic data
  // We map over the static config, creating a structure with a "factory" for the dynamic part.
  const actionListFactory = useMemo(() => {
    return staticActionsConfig.map(action => {
      if (action.id === 'edit') {
        return {
          icon: 'PiPencilDuotone', // Placeholder for IconType
          label: 'Edit',
          // We store a function that generates the path, rather than the path itself
          resolvePath: (id: number) => getPathForRecord(id) 
        };
      }
      return {
        icon: 'PiTrashDuotone',
        label: 'Delete',
        onClick: (record: any) => console.log('Deleting', record), // Use the passed record
      };
    });
  }, []); // Zero dependencies

  return (
    
        {data.records.map((d) => (
          
        ))}
      
({ ...item, // If it's the edit action, calculate the specific path now path: item.resolvePath ? item.resolvePath(d.id) : undefined }))} value={d} />
); }

How Senior Engineers Fix It

Senior engineers recognize that immutability and reference stability are the goals, not necessarily “zero object creation.”

  1. The “Item Component” Pattern (Best for Lists):
    Extract the <tr> and <CDropdown> into a separate component (<ActionRow />). Memoize that component (React.memo). Then, pass d (the record) as a prop. Inside the child component, you can calculate the path inside a useMemo that depends only on d.id.

    • Result: The list renders once. Only the specific row re-renders if its data changes.
  2. The “Configuration” Pattern (Used in Example):
    Separate the static (icons, labels) from the dynamic (IDs, paths).

    • Memoize the static array.
    • Perform a lightweight .map() during render to attach the dynamic path.
    • Senior engineers accept this small O(N) operation as “cheap” compared to the cost of memoizing complex logic or creating child components.
  3. Type Assertion Wrapper:
    Instead of as IRoutePath scattered everywhere, create a helper function createRoute(path: string): IRoutePath. This centralizes the type assertion logic into one validated location, allowing the rest of the code to remain clean.

Why Juniors Miss It

  • Scope Boundaries: They often confuse the “component render scope” with the “callback scope.” They expect hooks to “wait” for the loop to start.
  • “use” Everything: They learn that useMemo improves performance, so they try to wrap everything. They don’t yet realize that wrapping cheap code in hooks adds overhead (dependency comparison).
  • Type Blindness: They see string and IRoutePath and think a type assertion (as) is a fix for logic, rather than a sign that the data structure needs to change. They try to force the loop variable d into the hook’s dependency array, which is impossible.
  • Premature Optimization: They optimize the wrong thing. Creating 100 arrays is usually negligible for the JS engine. The real performance killer is often DOM reflows or complex re-renders in child components, which the useCallback approach might not fix.