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:
useMemoexecutes during the render phase before thedata.records.map()iteration begins. Therefore, the variabled(representing the current row) is out of scope and undefined inside theuseMemohook. - Type Safety vs. Dynamic Composition: The
IRoutePathis a strict union type. A template literal like`/planning/design/item/${d.id}`evaluates tostring, which is not assignable toIRoutePathwithout a type assertion. You cannot generate a literal type at runtime. - Re-render Cost: The current
useCallbackapproach returns a new array reference for every single row. IfCDropdownusesReact.memo, this defeats the memoization because thelistprop 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/useCallbackuniversally 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 Typeassertions 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.”
-
The “Item Component” Pattern (Best for Lists):
Extract the<tr>and<CDropdown>into a separate component (<ActionRow />). Memoize that component (React.memo). Then, passd(the record) as a prop. Inside the child component, you can calculate the path inside auseMemothat depends only ond.id.- Result: The list renders once. Only the specific row re-renders if its data changes.
-
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 dynamicpath. - Senior engineers accept this small O(N) operation as “cheap” compared to the cost of memoizing complex logic or creating child components.
-
Type Assertion Wrapper:
Instead ofas IRoutePathscattered everywhere, create a helper functioncreateRoute(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
useMemoimproves 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
stringandIRoutePathand 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 variabledinto 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
useCallbackapproach might not fix.