Repository: adobe/react-spectrum
Pull request #8211: fix: Table column updating
Author: login: snowystinger
Labels: name: release
Created: 2025-05-09T04:58:13Z | Merged: 2025-05-10T00:19:06Z

Closes https://github.com/adobe/react-spectrum/issues/8127, https://github.com/adobe/react-spectrum/issues/7345

Combination of examples and docs and some fixes to Document. Some of the changes were borrowed from @LFDanLu 's PR  https://github.com/adobe/react-spectrum/pull/7938/files

## ✅ Pull Request Checklist:

- [ ] Included link to corresponding [React Spectrum GitHub Issue](https://github.com/adobe/react-spectrum/issues).
- [ ] Added/updated unit tests and storybook for this change (for new code or code which already has tests).
- [ ] Filled out test instructions.
- [ ] Updated documentation (if it already exists for this component).
- [ ] Looked at the Accessibility Practices for this feature - [Aria Practices](https://www.w3.org/WAI/ARIA/apg/)

## 📝 Test Instructions:

<!--- Include instructions to test this pull request -->

## 🧢 Your Project:

<!--- Company/project for pull request -->



Diff:
diff --git a/packages/@react-aria/collections/src/Document.ts b/packages/@react-aria/collections/src/Document.ts
index 71bd6cae007..81877965941 100644
--- a/packages/@react-aria/collections/src/Document.ts
+++ b/packages/@react-aria/collections/src/Document.ts
@@ -182,6 +182,10 @@ export class BaseNode<T> {
       return;
     }
 
+    if (this._minInvalidChildIndex === child) {
+      this._minInvalidChildIndex = null;
+    }
+
     if (child.nextSibling) {
       this.invalidateChildIndices(child.nextSibling);
       child.nextSibling.previousSibling = child.previousSibling;
diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx
index a259d3cf464..6397f89d3f5 100644
--- a/packages/@react-spectrum/s2/src/TableView.tsx
+++ b/packages/@react-spectrum/s2/src/TableView.tsx
@@ -369,13 +369,13 @@ const centeredWrapper = style({
   height: 'full'
 });
 
-export interface TableBodyProps<T> extends Omit<RACTableBodyProps<T>, 'style' | 'className' | 'dependencies'> {}
+export interface TableBodyProps<T> extends Omit<RACTableBodyProps<T>, 'style' | 'className'> {}
 
 /**
  * The body of a `<Table>`, containing the table rows.
  */
 export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function TableBody<T extends object>(props: TableBodyProps<T>, ref: DOMRef<HTMLDivElement>) {
-  let {items, renderEmptyState, children} = props;
+  let {items, renderEmptyState, children, dependencies = []} = props;
   let domRef = useDOMRef(ref);
   let {loadingState, onLoadMore} = useContext(InternalTableContext);
   let isLoading = loadingState === 'loading' || loadingState === 'loadingMore';
@@ -402,7 +402,7 @@ export const TableBody = /*#__PURE__*/ (forwardRef as forwardRefType)(function T
   if (typeof children === 'function' && items) {
     renderer = (
       <>
-        <Collection items={items}>
+        <Collection items={items} dependencies={dependencies}>
           {children}
         </Collection>
         {loadMoreSpinner}
@@ -1115,12 +1115,12 @@ const row = style<RowRenderProps & S2TableProps>({
   forcedColorAdjust: 'none'
 });
 
-export interface RowProps<T> extends Pick<RACRowProps<T>, 'id' | 'columns' | 'children' | 'textValue'>  {}
+export interface RowProps<T> extends Pick<RACRowProps<T>, 'id' | 'columns' | 'children' | 'textValue' | 'dependencies'>  {}
 
 /**
  * A row within a `<Table>`.
  */
-export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row<T extends object>({id, columns, children, ...otherProps}: RowProps<T>, ref: DOMRef<HTMLDivElement>) {
+export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row<T extends object>({id, columns, children, dependencies = [], ...otherProps}: RowProps<T>, ref: DOMRef<HTMLDivElement>) {
   let {selectionBehavior, selectionMode} = useTableOptions();
   let tableVisualOptions = useContext(InternalTableContext);
   let domRef = useDOMRef(ref);
@@ -1130,6 +1130,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row<T e
       // @ts-ignore
       ref={domRef}
       id={id}
+      dependencies={[...dependencies, columns]}
       className={renderProps => row({
         ...renderProps,
         ...tableVisualOptions
@@ -1140,7 +1141,7 @@ export const Row = /*#__PURE__*/ (forwardRef as forwardRefType)(function Row<T e
           <Checkbox isEmphasized slot="selection" />
         </Cell>
       )}
-      <Collection items={columns}>
+      <Collection items={columns} dependencies={[...dependencies, columns]}>
         {children}
       </Collection>
     </RACRow>
diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx
index 08d2c7f5f7d..fa1044d34e4 100644
--- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx
+++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx
@@ -153,6 +153,38 @@ const DynamicTable = (args: any) => (
   </TableView>
 );
 
+export const DynamicColumns = {
+  render: function DynamicColumnsExample(args: any) {
+    let [cols, setColumns] = useState(columns);
+    return (
+      <div style={{display: 'flex', flexDirection: 'column', gap: 12}}>
+        <ActionButton onPress={() => setColumns((prev) => prev.length > 3 ? [columns[0]].concat(columns.slice(2, 4)) : columns)}>Toggle columns</ActionButton>
+        <TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
+          <TableHeader columns={cols}>
+            {(column) => (
+              <Column width={150} minWidth={150} isRowHeader={column.isRowHeader}>{column.name}</Column>
+            )}
+          </TableHeader>
+          <TableBody items={items} dependencies={[cols]}>
+            {item => (
+              <Row id={item.id} columns={cols}>
+                {(col) => {
+                  return <Cell>{item[col.id]}</Cell>;
+                }}
+              </Row>
+            )}
+          </TableBody>
+        </TableView>
+      </div>
+    );
+  },
+  args: Example.args,
+  parameters: {
+    docs: {
+      disable: true
+    }
+  }
+};
 
 const DynamicTableWithCustomMenus = (args: any) => (
   <TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
@@ -817,39 +849,50 @@ export const FlexWidth = {
 };
 
 
+function ColSpanExample(args: any) {
+  let [hide, setHide] = useState(false);
+  return (
+    <div style={{display: 'flex', flexDirection: 'column', gap: 12}}>
+      <ActionButton onPress={() => setHide(!hide)}>{hide ? 'Show' : 'Hide'}</ActionButton>
+      <TableView aria-label="Files" {...args} styles={style({width: 320, height: 320})}>
+        <TableHeader>
+          <Column isRowHeader>Name</Column>
+          <Column>Type</Column>
+          {!hide && <Column>Date Modified</Column>}
+          <Column>Size</Column>
+        </TableHeader>
+        <TableBody>
+          <Row id="1">
+            <Cell>Games</Cell>
+            <Cell>File folder</Cell>
+            {!hide && <Cell>6/7/2020</Cell>}
+            <Cell>74 GB</Cell>
+          </Row>
+          <Row id="2">
+            <Cell>Program Files</Cell>
+            <Cell>File folder</Cell>
+            {!hide && <Cell>4/7/2021</Cell>}
+            <Cell>1.2 GB</Cell>
+          </Row>
+          <Row id="3">
+            <Cell>bootmgr</Cell>
+            <Cell>System file</Cell>
+            {!hide && <Cell>11/20/2010</Cell>}
+            <Cell>0.2 GB</Cell>
+          </Row>
+          <Row id="4">
+            <Cell colSpan={hide ? 3 : 4}>Total size: 75.4 GB</Cell>
+          </Row>
+        </TableBody>
+      </TableView>
+    </div>
+  );
+}
+
+
 export const ColSpan = {
   render: (args: any) => (
-    <TableView aria-label="Files" {...args} styles={style({width: 320, height: 320})}>
-      <TableHeader>
-        <Column isRowHeader>Name</Column>
-        <Column>Type</Column>
-        <Column>Date Modified</Column>
-        <Column>Size</Column>
-      </TableHeader>
-      <TableBody>
-        <Row id="1">
-          <Cell>Games</Cell>
-          <Cell>File folder</Cell>
-          <Cell>6/7/2020</Cell>
-          <Cell>74 GB</Cell>
-        </Row>
-        <Row id="2">
-          <Cell>Program Files</Cell>
-          <Cell>File folder</Cell>
-          <Cell>4/7/2021</Cell>
-          <Cell>1.2 GB</Cell>
-        </Row>
-        <Row id="3">
-          <Cell>bootmgr</Cell>
-          <Cell>System file</Cell>
-          <Cell>11/20/2010</Cell>
-          <Cell>0.2 GB</Cell>
-        </Row>
-        <Row id="4">
-          <Cell colSpan={4}>Total size: 75.4 GB</Cell>
-        </Row>
-      </TableBody>
-    </TableView>
+    <ColSpanExample {...args} />
   ),
   parameters: {
     docs: {
diff --git a/packages/dev/docs/pages/react-aria/collections.mdx b/packages/dev/docs/pages/react-aria/collections.mdx
index 398fbe4f86e..ab1e8240b8a 100644
--- a/packages/dev/docs/pages/react-aria/collections.mdx
+++ b/packages/dev/docs/pages/react-aria/collections.mdx
@@ -55,22 +55,22 @@ components like action menus where the items are built into the application rath
 
 ### Sections
 
-Sections or groups of items can be constructed by wrapping the items in a `<Section>` element. A `<Header>` can also be
-rendered within a `<Section>` to provide a section title.
+Sections or groups of items can be constructed by wrapping the items in a section element. A `<Header>` can also be
+rendered within a section to provide a section title.
 
 ```tsx
 <Menu>
-  <Section>
+  <MenuSection>
     <Header>Styles</Header>
     <MenuItem>Bold</MenuItem>
     <MenuItem>Underline</MenuItem>
-  </Section>
-  <Section>
+  </MenuSection>
+  <MenuSection>
     <Header>Align</Header>
     <MenuItem>Left</MenuItem>
     <MenuItem>Middle</MenuItem>
     <MenuItem>Right</MenuItem>
-  </Section>
+  </MenuSection>
 </Menu>
 ```
 
@@ -179,9 +179,28 @@ function addAnimal(name) {
 
 Note that `useListData` is a convenience hook, not a requirement. You can use any state management library to manage collection items.
 
+### Dependencies
+
+As described above, dynamic collections are automatically memoized to improve performance. Rendered item elements are cached based on the object identity
+of the list item. If rendering an item depends on additional external state, the `dependencies` prop must be provided. This invalidates rendered elements 
+similar to dependencies in React's `useMemo` hook.
+
+```tsx
+function Example(props) {
+  return (
+    <ListBox items={items} dependencies={[props.layout]}>
+      {item => <MyItem layout={props.layout}>{item.name}</MyItem>}
+    </ListBox>
+  );
+}
+```
+
+Note that adding dependencies will result in the _entire_ list being invalidated when a dependency changes. To avoid this and invalidate only an individual item,
+update the item object itself to include the information rather than accessing it from external state.
+
 ### Sections
 
-Sections can be built by returning a `<Section>` instead of an item from the top-level item renderer. Sections
+Sections can be built by returning a section instead of an item from the top-level item renderer. Sections
 also support an `items` prop and a render function for their children. If the section also has a header,
 the <TypeLink links={docs.links} type={docs.exports.Collection} /> component can be used to render the child items.
 
@@ -207,12 +226,12 @@ let [sections, setSections] = useState([
 
 <ListBox items={sections}>
   {section =>
-    <Section id={section.name}>
+    <ListBoxSection id={section.name}>
       <Header>{section.name}</Header>
       <Collection items={section.children}>
         {item => <ListBoxItem id={item.name}>{item.name}</ListBoxItem>}
       </Collection>
-    </Section>
+    </ListBoxSection>
   }
 </ListBox>
 ```
@@ -261,12 +280,12 @@ function addPerson(name) {
 
 <ListBox items={tree.items}>
   {node =>
-    <Section id={section.name} items={node.children}>
+    <ListBoxSection id={section.name} items={node.children}>
       <Header>{section.name}</Header>
       <Collection items={section.children}>
         {item => <ListBoxItem id={item.name}>{item.name}</ListBoxItem>}
       </Collection>
-    </Section>
+    </ListBoxSection>
   }
 </ListBox>
 ```
diff --git a/packages/react-aria-components/docs/CheckboxGroup.mdx b/packages/react-aria-components/docs/CheckboxGroup.mdx
index fc71366a3ba..b4d5082e6cb 100644
--- a/packages/react-aria-components/docs/CheckboxGroup.mdx
+++ b/packages/react-aria-components/docs/CheckboxGroup.mdx
@@ -185,7 +185,7 @@ interface MyCheckboxGroupProps extends Omit<CheckboxGroupProps, 'children'> {
   errorMessage?: string | ((validation: ValidationResult) => string)
 }
 
-function MyCheckboxGroup({
+export function MyCheckboxGroup({
   label,
   description,
   errorMessage,
diff --git a/packages/react-aria-components/docs/Table.mdx b/packages/react-aria-components/docs/Table.mdx
index 65d4ddd4fe3..72b06a29a0c 100644
--- a/packages/react-aria-components/docs/Table.mdx
+++ b/packages/react-aria-components/docs/Table.mdx
@@ -93,6 +93,7 @@ import {MyCheckbox} from './Checkbox';
 ```css hidden
 @import './Button.mdx' layer(button);
 @import './ToggleButton.mdx' layer(togglebutton);
+@import './CheckboxGroup.mdx' layer(checkboxgroup);
 @import './Checkbox.mdx' layer(checkbox);
 @import './Popover.mdx' layer(popover);
 @import './Menu.mdx' layer(menu);
@@ -431,47 +432,77 @@ Now we can render a table with a default selection column built in.
 
 So far, our examples have shown static collections, where the data is hard coded.
 Dynamic collections, as shown below, can be used when the table data comes from an external data source such as an API, or updates over time.
-In the example below, both the columns and the rows are provided to the table via a render function. You can also make the columns static and
-only the rows dynamic.
+In the example below, both the columns and the rows are provided to the table via a render function, enabling the user to hide and show columns
+and add additional rows. You can also make the columns static and only the rows dynamic.
+
+**Note**: Dynamic collections are automatically memoized to improve performance. Use the `dependencies` prop to invalidate cached elements that depend
+on external state (e.g. `columns` in this example). See the [collections](collections.html#dependencies) guide for more details.
 
 ```tsx example export=true
 import type {TableProps} from 'react-aria-components';
+import {MyCheckboxGroup} from './CheckboxGroup';
+import {MyCheckbox} from './Checkbox';
 
 function FileTable(props: TableProps) {
+  let [showColumns, setShowColumns] = React.useState(['name', 'type', 'date']);
   let columns = [
     {name: 'Name', id: 'name', isRowHeader: true},
     {name: 'Type', id: 'type'},
     {name: 'Date Modified', id: 'date'}
-  ];
+  ].filter(column => showColumns.includes(column.id));
 
-  let rows = [
+  let [rows, setRows] = React.useState([
     {id: 1, name: 'Games', date: '6/7/2020', type: 'File folder'},
     {id: 2, name: 'Program Files', date: '4/7/2021', type: 'File folder'},
     {id: 3, name: 'bootmgr', date: '11/20/2010', type: 'System file'},
     {id: 4, name: 'log.txt', date: '1/18/2016', type: 'Text Document'}
-  ];
+  ]);
+
+  let addRow = () => {
+    let date = new Date().toLocaleDateString();
+    setRows(rows => [
+      ...rows,
+      {id: rows.length + 1, name: 'file.txt', date, type: 'Text Document'}
+    ]);
+  };
 
   return (
-    <Table aria-label="Files" {...props}>
-      <MyTableHeader columns={columns}>
-        {column => (
-          <Column isRowHeader={column.isRowHeader}>
-            {column.name}
-          </Column>
-        )}
-      </MyTableHeader>
-      <TableBody items={rows}>
-        {item => (
-          <MyRow columns={columns}>
-            {column => <Cell>{item[column.id]}</Cell>}
-          </MyRow>
-        )}
-      </TableBody>
-    </Table>
+    <div className="flex-col">
+      <MyCheckboxGroup aria-label="Show columns" value={showColumns} onChange={setShowColumns} style={{flexDirection: 'row'}}>
+        <MyCheckbox value="type">Type</MyCheckbox>
+        <MyCheckbox value="date">Date Modified</MyCheckbox>
+      </MyCheckboxGroup>
+      <Table aria-label="Files" {...props}>
+        <MyTableHeader columns={columns}>
+          {column => (
+            <Column isRowHeader={column.isRowHeader}>
+              {column.name}
+            </Column>
+          )}
+        </MyTableHeader>
+        <TableBody items={rows} dependencies={[columns]}>
+          {item => (
+            <MyRow columns={columns}>
+              {column => <Cell>{item[column.id]}</Cell>}
+            </MyRow>
+          )}
+        </TableBody>
+      </Table>
+      <Button onPress={addRow}>Add row</Button>
+    </div>
   );
 }
 ```
 
+```css hidden
+.flex-col {
+  display: flex;
+  flex-direction: column;
+  gap: 8;
+  align-items: start;
+}
+```
+
 ## Selection
 
 ### Single selection
diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js
index 08e6e2fc278..50039382b48 100644
--- a/packages/react-aria-components/test/Table.test.js
+++ b/packages/react-aria-components/test/Table.test.js
@@ -2332,4 +2332,160 @@ describe('Table', () => {
       expect(onSelectionChange).toBeCalledTimes(1);
     });
   });
+
+  it('supports removing a column and adding it back static', async () => {
+    let {getByRole} = render(<HidingColumnsExample />);
+    let button = getByRole('button');
+    expect(button).toHaveTextContent('Hide Columns');
+
+    await user.click(button);
+    expect(button).toHaveTextContent('Show Columns');
+
+    await user.click(button);
+    expect(button).toHaveTextContent('Hide Columns');
+  });
+
+  it('supports removing a column and adding it back dynamic', async () => {
+    let {getByRole} = render(<HidingColumnsExample dynamic />);
+    let button = getByRole('button');
+    expect(button).toHaveTextContent('Hide Columns');
+
+    await user.click(button);
+    expect(button).toHaveTextContent('Show Columns');
+
+    await user.click(button);
+    expect(button).toHaveTextContent('Hide Columns');
+  });
 });
+
+function HidingColumnsExample({dynamic = false}) {
+  const [hideColumns, setHideColumns] = React.useState(false);
+
+  const label = hideColumns ? 'Show Columns' : 'Hide Columns';
+
+  return (
+    <>
+      {dynamic ? <StockTableExampleDynamic hideColumns={hideColumns} /> : <StockTableExampleStatic hideColumns={hideColumns} />}
+      <button onClick={() => setHideColumns((prev) => !prev)}>{label}</button>
+    </>
+  );
+}
+
+function StockTableExampleDynamic({hideColumns}) {
+  return (
+    <div>
+      <ResizableTableContainer>
+        <Table
+          aria-label="Stocks"
+          selectionMode="multiple"
+          selectionBehavior="replace">
+          <TableHeader>
+            <Column id="symbol">
+              Symbol
+            </Column>
+            <Column id="name" isRowHeader>
+              Name
+            </Column>
+            {!hideColumns && (
+              <Column id="marketCap">
+                Market Cap
+              </Column>
+            )}
+            {!hideColumns && (
+              <Column id="sector">
+                Sector
+              </Column>
+            )}
+            <Column id="industry">
+              Industry
+            </Column>
+          </TableHeader>
+          <TableBody items={stocks} dependencies={[hideColumns]}>
+            {(item) => (
+              <Row id={item.symbol}>
+                <Cell>
+                  <span>
+                    ${item.symbol}
+                  </span>
+                </Cell>
+                <Cell>{item.name}</Cell>
+                {!hideColumns && <Cell>{item.marketCap}</Cell>}
+                {!hideColumns && <Cell>{item.sector}</Cell>}
+                <Cell>{item.industry}</Cell>
+              </Row>
+            )}
+          </TableBody>
+        </Table>
+      </ResizableTableContainer>
+    </div>
+  );
+}
+
+function StockTableExampleStatic({hideColumns}) {
+  return (
+    <div>
+      <ResizableTableContainer>
+        <Table
+          aria-label="Stocks"
+          selectionMode="multiple"
+          selectionBehavior="replace">
+          <TableHeader>
+            <Column id="symbol">
+              Symbol
+            </Column>
+            <Column id="name" isRowHeader>
+              Name
+            </Column>
+            {!hideColumns && (
+              <Column id="marketCap">
+                Market Cap
+              </Column>
+            )}
+            {!hideColumns && (
+              <Column id="sector">
+                Sector
+              </Column>
+            )}
+            <Column id="industry">
+              Industry
+            </Column>
+          </TableHeader>
+          <TableBody>
+            {stocks.map((item) => (
+              <Row id={item.symbol} key={item.symbol}>
+                <Cell>
+                  <span>
+                    ${item.symbol}
+                  </span>
+                </Cell>
+                <Cell>{item.name}</Cell>
+                {!hideColumns && <Cell>{item.marketCap}</Cell>}
+                {!hideColumns && <Cell>{item.sector}</Cell>}
+                <Cell>{item.industry}</Cell>
+              </Row>
+            ))}
+          </TableBody>
+        </Table>
+      </ResizableTableContainer>
+    </div>
+  );
+}
+
+const stocks = [
+  {
+    id: 1,
+    symbol: 'PAACR',
+    name: 'Pacific Special Acquisition Corp.',
+    sector: 'Finance',
+    marketCap: 'n/a',
+    industry: 'Business Services'
+  },
+  {
+    id: 2,
+    symbol: 'PAACR-DEDUPE',
+    name: 'Pacific Special Acquisition Corp.',
+    sector: 'Finance',
+    marketCap: 'n/a',
+    industry: 'Business Services'
+  }
+];


Reviews:
user: login: snowystinger
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T05:00:22Z
user: login: snowystinger
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T05:00:37Z
user: login: snowystinger
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T05:01:31Z
user: login: snowystinger
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T05:01:42Z
user: login: devongovett
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T18:19:15Z
user: login: devongovett
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T18:42:02Z
user: login: devongovett
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T20:51:56Z
user: login: LFDanLu
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-09T22:59:15Z
user: login: devongovett
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-09T23:37:22Z
user: login: LFDanLu
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-10T00:00:36Z
user: login: devongovett
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-10T00:02:16Z

Review comments:
diff_hunk: @@ -1208,6 +1208,75 @@ function Example() {
 }
 ```
 
+## Dependencies
path: packages/react-aria-components/docs/Table.mdx
user: login: snowystinger
body: name title of section?
created_at: 2025-05-09T05:00:22Z
author_association: MEMBER
diff_hunk: @@ -1208,6 +1208,75 @@ function Example() {
 }
 ```
 
+## Dependencies
+
+The `dependencies` prop can be used to ensure that separate parts of the collection update in response to each other, for example, column additions and removals.
+Be careful using it too frequently as it will cause the table to re-render more often than necessary which can cause performance degradation.
+Use the same strategies for reducing unnecessary invalidations as you would with any other hook.
+
+This prop is an array of dependencies that will trigger a cache invalidation within the collection. It should follow the same rules as React various hooks with dependencies arrays.
+> The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`.
path: packages/react-aria-components/docs/Table.mdx
user: login: snowystinger
body: link to React docs? I don't know if we follow exactly the same algorithm for comparing
created_at: 2025-05-09T05:00:37Z
author_association: MEMBER
diff_hunk: @@ -134,25 +134,30 @@ let items = [
   {id: 10, foo: 'Foo 10', bar: 'Bar 10', baz: 'Baz 10', yah: 'Yah long long long 10'}
 ];
 
-const DynamicTable = (args: any) => (
-  <TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
-    <TableHeader columns={columns}>
-      {(column) => (
-        <Column width={150} minWidth={150} isRowHeader={column.isRowHeader}>{column.name}</Column>
-      )}
-    </TableHeader>
-    <TableBody items={items}>
-      {item => (
-        <Row id={item.id} columns={columns}>
-          {(column) => {
-            return <Cell>{item[column.id]}</Cell>;
-          }}
-        </Row>
-      )}
-    </TableBody>
-  </TableView>
-);
-
+const DynamicTable = (args: any) => {
+  let [cols, setColumns] = useState(columns);
+  return (
+    <div style={{display: 'flex', flexDirection: 'column', gap: 12}}>
+      <ActionButton onPress={() => setColumns((prev) => prev.length > 3 ? columns.slice(0, 3) : columns)}>Remove columns</ActionButton>
+      <TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
+        <TableHeader columns={cols}>
+          {(column) => (
+            <Column width={150} minWidth={150} isRowHeader={column.isRowHeader}>{column.name}</Column>
+          )}
+        </TableHeader>
+        <TableBody items={items} dependencies={[cols]}>
+          {item => (
+            <Row id={item.id} columns={cols} dependencies={[cols]}>
+              {(col) => {
+                return <Cell>{item[col.id]}</Cell>;
+              }}
+            </Row>
path: packages/@react-spectrum/s2/stories/TableView.stories.tsx
user: login: snowystinger
body: this example isn't working for some reason??
created_at: 2025-05-09T05:01:31Z
author_association: MEMBER
diff_hunk: @@ -134,25 +134,30 @@ let items = [
   {id: 10, foo: 'Foo 10', bar: 'Bar 10', baz: 'Baz 10', yah: 'Yah long long long 10'}
 ];
 
-const DynamicTable = (args: any) => (
-  <TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
-    <TableHeader columns={columns}>
-      {(column) => (
-        <Column width={150} minWidth={150} isRowHeader={column.isRowHeader}>{column.name}</Column>
-      )}
-    </TableHeader>
-    <TableBody items={items}>
-      {item => (
-        <Row id={item.id} columns={columns}>
-          {(column) => {
-            return <Cell>{item[column.id]}</Cell>;
-          }}
-        </Row>
-      )}
-    </TableBody>
-  </TableView>
-);
-
+const DynamicTable = (args: any) => {
+  let [cols, setColumns] = useState(columns);
+  return (
+    <div style={{display: 'flex', flexDirection: 'column', gap: 12}}>
+      <ActionButton onPress={() => setColumns((prev) => prev.length > 3 ? columns.slice(0, 3) : columns)}>Remove columns</ActionButton>
+      <TableView aria-label="Dynamic table" {...args} styles={style({width: 320, height: 208})}>
+        <TableHeader columns={cols}>
+          {(column) => (
+            <Column width={150} minWidth={150} isRowHeader={column.isRowHeader}>{column.name}</Column>
+          )}
+        </TableHeader>
+        <TableBody items={items} dependencies={[cols]}>
+          {item => (
+            <Row id={item.id} columns={cols} dependencies={[cols]}>
+              {(col) => {
+                return <Cell>{item[col.id]}</Cell>;
+              }}
+            </Row>
path: packages/@react-spectrum/s2/stories/TableView.stories.tsx
user: login: snowystinger
body: this example isn't working for some reason??
created_at: 2025-05-09T05:01:41Z
author_association: MEMBER
diff_hunk: @@ -177,7 +181,11 @@ export class BaseNode<T> {
     if (child.parentNode !== this || !this.ownerDocument.isMounted) {
       return;
     }
-    
+
+    if (this._minInvalidChildIndex === child && child.previousSibling) {
+      this.invalidateChildIndices(child.previousSibling);
path: packages/@react-aria/collections/src/Document.ts
user: login: devongovett
body: Shouldn't it move to the next sibling? After removing a child, the next sibling will now have a new index. The previous sibling won't change. Also what happens if you remove the first child?
created_at: 2025-05-09T18:19:15Z
author_association: MEMBER
diff_hunk: @@ -177,7 +181,11 @@ export class BaseNode<T> {
     if (child.parentNode !== this || !this.ownerDocument.isMounted) {
       return;
     }
-    
+
+    if (this._minInvalidChildIndex === child && child.previousSibling) {
+      this.invalidateChildIndices(child.previousSibling);
path: packages/@react-aria/collections/src/Document.ts
user: login: devongovett
body: actually I see I already had that below. I think we can just set minInvalidChildIndex to null here.
created_at: 2025-05-09T18:42:01Z
author_association: MEMBER
diff_hunk: @@ -1208,6 +1208,75 @@ function Example() {
 }
 ```
 
+## Dependencies
+
+The `dependencies` prop can be used to ensure that separate parts of the collection update in response to each other, for example, column additions and removals.
+Be careful using it too frequently as it will cause the table to re-render more often than necessary which can cause performance degradation.
+Use the same strategies for reducing unnecessary invalidations as you would with any other hook.
+
+This prop is an array of dependencies that will trigger a cache invalidation within the collection. It should follow the same rules as React various hooks with dependencies arrays.
+> The list of dependencies must have a constant number of items and be written inline like `[dep1, dep2, dep3]`.
path: packages/react-aria-components/docs/Table.mdx
user: login: devongovett
body: We pass the dependencies directly to useMemo internally :)
created_at: 2025-05-09T20:51:55Z
author_association: MEMBER

PR comments:
user: login: rspbot
created_at: 2025-05-09T05:03:38Z
body: Build successful! 🎉

  * [View the storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/e6e8d6f0d767a70f5060aca74fb0e1545cede1e6/storybook/index.html)
  * [View the storybook-19](https://reactspectrum.blob.core.windows.net/reactspectrum/e6e8d6f0d767a70f5060aca74fb0e1545cede1e6/storybook-19/index.html)
  * [View the storybook-17](https://reactspectrum.blob.core.windows.net/reactspectrum/e6e8d6f0d767a70f5060aca74fb0e1545cede1e6/storybook-17/index.html)
  * [View the storybook-16](https://reactspectrum.blob.core.windows.net/reactspectrum/e6e8d6f0d767a70f5060aca74fb0e1545cede1e6/storybook-16/index.html)
  * [View the S2 storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/e6e8d6f0d767a70f5060aca74fb0e1545cede1e6/storybook-s2/index.html)
  * [View the documentation](https://reactspectrum.blob.core.windows.net/reactspectrum/e6e8d6f0d767a70f5060aca74fb0e1545cede1e6/docs/index.html)
author_association: NONE
user: login: devongovett
created_at: 2025-05-09T20:58:34Z
body: Merged the dependencies example in Table docs into the Content section, which covers dynamic collections. Added checkbox group to show/hide columns, and button to add rows.
author_association: MEMBER
user: login: rspbot
created_at: 2025-05-09T21:06:12Z
body: Build successful! 🎉

  * [View the storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/6164c8bfa32d31c9741fc75e6fee81f29439aa1e/storybook/index.html)
  * [View the storybook-19](https://reactspectrum.blob.core.windows.net/reactspectrum/6164c8bfa32d31c9741fc75e6fee81f29439aa1e/storybook-19/index.html)
  * [View the storybook-17](https://reactspectrum.blob.core.windows.net/reactspectrum/6164c8bfa32d31c9741fc75e6fee81f29439aa1e/storybook-17/index.html)
  * [View the storybook-16](https://reactspectrum.blob.core.windows.net/reactspectrum/6164c8bfa32d31c9741fc75e6fee81f29439aa1e/storybook-16/index.html)
  * [View the S2 storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/6164c8bfa32d31c9741fc75e6fee81f29439aa1e/storybook-s2/index.html)
  * [View the documentation](https://reactspectrum.blob.core.windows.net/reactspectrum/6164c8bfa32d31c9741fc75e6fee81f29439aa1e/docs/index.html)
author_association: NONE
user: login: rspbot
created_at: 2025-05-09T22:25:37Z
body: Build successful! 🎉

  * [View the storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/d3830872b99194b38a91f85d2f0f2a6d093c3816/storybook/index.html)
  * [View the storybook-19](https://reactspectrum.blob.core.windows.net/reactspectrum/d3830872b99194b38a91f85d2f0f2a6d093c3816/storybook-19/index.html)
  * [View the storybook-17](https://reactspectrum.blob.core.windows.net/reactspectrum/d3830872b99194b38a91f85d2f0f2a6d093c3816/storybook-17/index.html)
  * [View the storybook-16](https://reactspectrum.blob.core.windows.net/reactspectrum/d3830872b99194b38a91f85d2f0f2a6d093c3816/storybook-16/index.html)
  * [View the S2 storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/d3830872b99194b38a91f85d2f0f2a6d093c3816/storybook-s2/index.html)
  * [View the documentation](https://reactspectrum.blob.core.windows.net/reactspectrum/d3830872b99194b38a91f85d2f0f2a6d093c3816/docs/index.html)
author_association: NONE
user: login: rspbot
created_at: 2025-05-10T00:08:41Z
body: Build successful! 🎉

  * [View the storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/e9eb1c103731695961997152d55fac7b9f1d63a4/storybook/index.html)
  * [View the storybook-19](https://reactspectrum.blob.core.windows.net/reactspectrum/e9eb1c103731695961997152d55fac7b9f1d63a4/storybook-19/index.html)
  * [View the storybook-17](https://reactspectrum.blob.core.windows.net/reactspectrum/e9eb1c103731695961997152d55fac7b9f1d63a4/storybook-17/index.html)
  * [View the storybook-16](https://reactspectrum.blob.core.windows.net/reactspectrum/e9eb1c103731695961997152d55fac7b9f1d63a4/storybook-16/index.html)
  * [View the S2 storybook](https://reactspectrum.blob.core.windows.net/reactspectrum/e9eb1c103731695961997152d55fac7b9f1d63a4/storybook-s2/index.html)
  * [View the documentation](https://reactspectrum.blob.core.windows.net/reactspectrum/e9eb1c103731695961997152d55fac7b9f1d63a4/docs/index.html)
author_association: NONE
user: login: rspbot
created_at: 2025-05-10T00:08:42Z
body: <!-- APIDifferComment -->## API Changes

### @react-spectrum/s2

#### /@react-spectrum/s2:TableBody
```diff
 TableBody <T extends {}> {
   children?: ReactNode | (T) => ReactNode
+  dependencies?: ReadonlyArray<any>
   items?: Iterable<T>
   renderEmptyState?: (TableBodyRenderProps) => ReactNode
 }
```


#### /@react-spectrum/s2:Row
```diff
 Row <T extends {}> {
   children?: ReactNode | (T) => ReactElement
   columns?: Iterable<T>
+  dependencies?: ReadonlyArray<any>
   id?: Key
   textValue?: string
 }
```


#### /@react-spectrum/s2:TableBodyProps
```diff
 TableBodyProps <T> {
   children?: ReactNode | (T) => ReactNode
+  dependencies?: ReadonlyArray<any>
   items?: Iterable<T>
   renderEmptyState?: (TableBodyRenderProps) => ReactNode
 }
```


#### /@react-spectrum/s2:RowProps
```diff
 RowProps <T> {
   children?: ReactNode | (T) => ReactElement
   columns?: Iterable<T>
+  dependencies?: ReadonlyArray<any>
   id?: Key
   textValue?: string
 }
```

-----------------------------------



author_association: NONE