-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Description
Bug Report: cellMemo causes stale closures in event handlers
Description
When cellMemo is enabled (default true), DataTable cells are memoized and don't re-render when row object references remain the same. This causes event handlers in the body template to capture stale state values (closure issue), leading to incorrect behavior when the component state changes.
so what i discorved is that if i click on row one and i cosole log global state i see only one item in array
if i click on row two i see 2 items in global state
if i click on row 3 i see 3 items in global state
yet i expected to see 3 items always every time i click cause that my gloabal state but seems cellMemo was implemeted badly instead of memorising its causing stale closures in that each row stores a capture of the global state that existed when it was created
Environment
- PrimeReact version: "primereact": "^10.9.7",
- React version: "react": "19.2.3",
- Browser: chrome
Steps to Reproduce
ParentForm.tsx:
'use client'
import React from "react";
import { useForm } from "react-hook-form";
import ChildTable from "./ChildTable";
const ParentForm = () => {
const { setValue, watch } = useForm({
defaultValues: { items: [] }
});
const items = watch("items") || [];
return (
<div style={{ padding: "20px" }}>
<h2>Parent Form</h2>
<ChildTable items={items} setValue={setValue} />
</div>
);
};
export default ParentForm;ChildTable.tsx:
'use client'
import React, { useState, useEffect, useMemo } from "react";
import { DataTable } from "primereact/datatable";
import { Column } from "primereact/column";
import { Button } from "primereact/button";
interface Item {
id: number;
name: string;
featured: boolean;
}
const ChildTable = ({ items, setValue }: any) => {
const [localItems, setLocalItems] = useState<Item[]>(items);
const memoizedItems = useMemo(() => items, [items]);
useEffect(() => {
setLocalItems(items);
}, [memoizedItems]);
const addItem = () => {
const newItem = {
id: localItems.length + 1,
name: `Item ${localItems.length + 1}`,
featured: false
};
const updatedItems = [...localItems, newItem];
setLocalItems(updatedItems);
setValue("items", updatedItems);
};
const toggleFeatured = (index: number) => {
console.log(`🚀 Button ${index} clicked - localItems:`, localItems.length, localItems);
};
return (
<div>
<Button label="Add Item" onClick={addItem} style={{ marginBottom: "10px" }} />
<DataTable value={localItems}>
<Column field="name" header="Name" />
<Column
header="Actions"
body={(rowData, { rowIndex }) => (
<Button
label="Click Me"
onClick={() => toggleFeatured(rowIndex)}
/>
)}
/>
</DataTable>
</div>
);
};
export default ChildTable;Reproduction Steps:
- Click "Add Item" button three times to add 3 items
- Click the "Click Me" button on each row (Item 1, Item 2, Item 3)
Actual Behavior
Console output shows stale state:
🚀 Button 0 clicked - localItems: 1 [{id: 1, name: "Item 1", featured: false}]
🚀 Button 1 clicked - localItems: 2 [{…}, {…}]
🚀 Button 2 clicked - localItems: 3 [{…}, {…}, {…}]
Each button captures the localItems state from when that specific row was first rendered:
- Button 0 was created when array had 1 item → captures
localItems.length = 1 - Button 1 was created when array had 2 items → captures
localItems.length = 2 - Button 2 was created when array had 3 items → captures
localItems.length = 3
Expected Behavior
All buttons should see the current state:
🚀 Button 0 clicked - localItems: 3 [{…}, {…}, {…}]
🚀 Button 1 clicked - localItems: 3 [{…}, {…}, {…}]
🚀 Button 2 clicked - localItems: 3 [{…}, {…}, {…}]
Root Cause
When cellMemo={true} (default), DataTable memoizes cells based on row object references. When new items are added:
- Existing row objects maintain the same reference (shallow copy via spread operator)
- DataTable's memoization prevents re-rendering of existing row cells
- Event handlers in those cells retain closures over stale state values
- Only newly added rows get fresh closures with updated state
Workaround/Solution
Setting cellMemo={false} forces all cells to re-render on every update:
<DataTable value={localItems} cellMemo={false}>This works but may have performance implications with large datasets.
Discussion
While I understand cellMemo is designed for performance optimization, it creates a problematic interaction with React's closure behavior that:
- Is not immediately obvious to developers
- Violates the principle of least surprise
- Can cause hard-to-debug issues in real applications
Me i ecxpeceted that same comeponet to work as this one but i was supprised
'use client'
import React, { useState, useEffect, useMemo } from "react";
interface Item {
id: number;
name: string;
featured: boolean;
}
const ChildTable = ({ items, setValue }: any) => {
const [localItems, setLocalItems] = useState<Item[]>(items);
const memoizedItems = useMemo(() => items, [items]);
useEffect(() => {
setLocalItems(items);
}, [memoizedItems]);
const addItem = () => {
const newItem = {
id: localItems.length + 1,
name: `Item ${localItems.length + 1}`,
featured: false
};
const updatedItems = [...localItems, newItem];
setLocalItems(updatedItems);
setValue("items", updatedItems);
};
const toggleFeatured = (index: number) => {
console.log(`🚀 Button ${index} clicked - localItems length:`, localItems.length, localItems);
};
return (
<div>
<button onClick={addItem} style={{ marginBottom: "10px", padding: "10px" }}>
Add Item
</button>
<table border={1} style={{ width: "100%", marginTop: "10px" }}>
<thead>
<tr>
<th>Name</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{localItems.map((item, index) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>
<button onClick={() => toggleFeatured(index)}>
Click Me
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default ChildTable;