diff --git a/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.js b/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.js
new file mode 100644
index 000000000000..9a6b83dcd1f0
--- /dev/null
+++ b/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.js
@@ -0,0 +1,363 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import {
+ DataGridPro,
+ getDataGridUtilityClass,
+ GridEvents,
+ useGridApiContext,
+ useGridApiRef,
+ useGridRootProps,
+} from '@mui/x-data-grid-pro';
+import { unstable_composeClasses as composeClasses } from '@mui/base';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+
+export const isNavigationKey = (key) =>
+ key === 'Home' ||
+ key === 'End' ||
+ key.indexOf('Arrow') === 0 ||
+ key.indexOf('Page') === 0 ||
+ key === ' ';
+
+const ALL_ROWS = [
+ {
+ hierarchy: ['Sarah'],
+ jobTitle: 'Head of Human Resources',
+ recruitmentDate: new Date(2020, 8, 12),
+ id: 0,
+ },
+ {
+ hierarchy: ['Thomas'],
+ jobTitle: 'Head of Sales',
+ recruitmentDate: new Date(2017, 3, 4),
+ id: 1,
+ },
+ {
+ hierarchy: ['Thomas', 'Robert'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 11, 20),
+ id: 2,
+ },
+ {
+ hierarchy: ['Thomas', 'Karen'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 10, 14),
+ id: 3,
+ },
+ {
+ hierarchy: ['Thomas', 'Nancy'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2017, 10, 29),
+ id: 4,
+ },
+ {
+ hierarchy: ['Thomas', 'Daniel'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 7, 21),
+ id: 5,
+ },
+ {
+ hierarchy: ['Thomas', 'Christopher'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 7, 20),
+ id: 6,
+ },
+ {
+ hierarchy: ['Thomas', 'Donald'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2019, 6, 28),
+ id: 7,
+ },
+ {
+ hierarchy: ['Mary'],
+ jobTitle: 'Head of Engineering',
+ recruitmentDate: new Date(2016, 3, 14),
+ id: 8,
+ },
+ {
+ hierarchy: ['Mary', 'Jennifer'],
+ jobTitle: 'Tech lead front',
+ recruitmentDate: new Date(2016, 5, 17),
+ id: 9,
+ },
+ {
+ hierarchy: ['Mary', 'Jennifer', 'Anna'],
+ jobTitle: 'Front-end developer',
+ recruitmentDate: new Date(2019, 11, 7),
+ id: 10,
+ },
+ {
+ hierarchy: ['Mary', 'Michael'],
+ jobTitle: 'Tech lead devops',
+ recruitmentDate: new Date(2021, 7, 1),
+ id: 11,
+ },
+ {
+ hierarchy: ['Mary', 'Linda'],
+ jobTitle: 'Tech lead back',
+ recruitmentDate: new Date(2017, 0, 12),
+ id: 12,
+ },
+ {
+ hierarchy: ['Mary', 'Linda', 'Elizabeth'],
+ jobTitle: 'Back-end developer',
+ recruitmentDate: new Date(2019, 2, 22),
+ id: 13,
+ },
+ {
+ hierarchy: ['Mary', 'Linda', 'William'],
+ jobTitle: 'Back-end developer',
+ recruitmentDate: new Date(2018, 4, 19),
+ id: 14,
+ },
+];
+
+const columns = [
+ { field: 'jobTitle', headerName: 'Job Title', width: 200 },
+ {
+ field: 'recruitmentDate',
+ headerName: 'Recruitment Date',
+ type: 'date',
+ width: 150,
+ },
+];
+
+const getChildren = (parentPath) => {
+ const parentPathStr = parentPath.join('-');
+ return ALL_ROWS.filter(
+ (row) => row.hierarchy.slice(0, -1).join('-') === parentPathStr,
+ );
+};
+
+/**
+ * This is a naive implementation with terrible performances on a real dataset.
+ * This fake server is only here for demonstration purposes.
+ */
+const fakeDataFetcher = (parentPath = []) =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ const rows = getChildren(parentPath).map((row) => ({
+ ...row,
+ descendantCount: getChildren(row.hierarchy).length,
+ }));
+
+ resolve(rows);
+ }, 500 + Math.random() * 300);
+ });
+
+const getTreeDataPath = (row) => row.hierarchy;
+
+const useUtilityClasses = (ownerState) => {
+ const { classes } = ownerState;
+
+ const slots = {
+ root: ['treeDataGroupingCell'],
+ toggle: ['treeDataGroupingCellToggle'],
+ };
+
+ return composeClasses(slots, getDataGridUtilityClass, classes);
+};
+
+/**
+ * Reproduce the behavior of the `GridTreeDataGroupingCell` component in `@mui/x-data-grid-pro`
+ * But base the amount of children on a `row.descendantCount` property rather than on the internal lookups.
+ */
+const GroupingCellWithLazyLoading = (props) => {
+ const { id, field, rowNode, row, hideDescendantCount } = props;
+
+ const rootProps = useGridRootProps();
+ const apiRef = useGridApiContext();
+ const classes = useUtilityClasses({ classes: rootProps.classes });
+
+ const Icon = rowNode.childrenExpanded
+ ? rootProps.components.TreeDataCollapseIcon
+ : rootProps.components.TreeDataExpandIcon;
+
+ const handleKeyDown = (event) => {
+ if (event.key === ' ') {
+ event.stopPropagation();
+ }
+ if (isNavigationKey(event.key) && !event.shiftKey) {
+ apiRef.current.publishEvent(GridEvents.cellNavigationKeyDown, props, event);
+ }
+ };
+
+ const handleClick = (event) => {
+ apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded);
+ apiRef.current.setCellFocus(id, field);
+ event.stopPropagation();
+ };
+
+ return (
+
+
+ {row.descendantCount > 0 && (
+
+
+
+ )}
+
+
+ {rowNode.groupingKey}
+ {!hideDescendantCount && row.descendantCount > 0
+ ? ` (${row.descendantCount})`
+ : ''}
+
+
+ );
+};
+
+GroupingCellWithLazyLoading.propTypes = {
+ /**
+ * The column field of the cell that triggered the event.
+ */
+ field: PropTypes.string.isRequired,
+ hideDescendantCount: PropTypes.bool,
+ /**
+ * The grid row id.
+ */
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ /**
+ * The row model of the row that the current cell belongs to.
+ */
+ row: PropTypes.any.isRequired,
+ /**
+ * The node of the row that the current cell belongs to.
+ */
+ rowNode: PropTypes.shape({
+ /**
+ * The id of the row children.
+ */
+ children: PropTypes.arrayOf(
+ PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ ),
+ /**
+ * Current expansion status of the row.
+ */
+ childrenExpanded: PropTypes.bool,
+ /**
+ * 0-based depth of the row in the tree.
+ */
+ depth: PropTypes.number.isRequired,
+ /**
+ * The field used to group the children of this row.
+ * Is `null` if no field has been used to group the children of this row.
+ */
+ groupingField: PropTypes.oneOfType([PropTypes.oneOf([null]), PropTypes.string])
+ .isRequired,
+ /**
+ * The key used to group the children of this row.
+ */
+ groupingKey: PropTypes.oneOfType([
+ PropTypes.number,
+ PropTypes.string,
+ PropTypes.bool,
+ ]).isRequired,
+ /**
+ * The grid row id.
+ */
+ id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ /**
+ * If `true`, this node has been automatically added to fill a gap in the tree structure.
+ */
+ isAutoGenerated: PropTypes.bool,
+ /**
+ * The row id of the parent (null if this row is a top level row).
+ */
+ parent: PropTypes.oneOfType([
+ PropTypes.oneOf([null]),
+ PropTypes.number,
+ PropTypes.string,
+ ]).isRequired,
+ }).isRequired,
+};
+
+const CUSTOM_GROUPING_COL_DEF = {
+ renderCell: (params) => ,
+};
+
+export default function TreeDataLazyLoading() {
+ const apiRef = useGridApiRef();
+ const [rows, setRows] = React.useState([]);
+
+ React.useEffect(() => {
+ fakeDataFetcher().then(setRows);
+
+ const handleRowExpansionChange = async (node) => {
+ const row = apiRef.current.getRow(node.id);
+
+ if (!node.childrenExpanded || !row || row.childrenFetched) {
+ return;
+ }
+
+ apiRef.current.updateRows([
+ {
+ id: `placeholder-children-${node.id}`,
+ hierarchy: [...row.hierarchy, ''],
+ },
+ ]);
+
+ const childrenRows = await fakeDataFetcher(row.hierarchy);
+ apiRef.current.updateRows([
+ ...childrenRows,
+ { id: node.id, childrenFetched: true },
+ { id: `placeholder-children-${node.id}`, _action: 'delete' },
+ ]);
+
+ if (childrenRows.length) {
+ apiRef.current.setRowChildrenExpansion(node.id, true);
+ }
+ };
+
+ /**
+ * By default, the grid does not toggle the expansion of rows with 0 children
+ * We need to override the `cellKeyDown` event listener to force the expansion if there are children on the server
+ */
+ const handleCellKeyDown = (params, event) => {
+ const cellParams = apiRef.current.getCellParams(params.id, params.field);
+ if (cellParams.colDef.type === 'treeDataGroup' && event.key === ' ') {
+ event.stopPropagation();
+ event.preventDefault();
+ event.defaultMuiPrevented = true;
+
+ apiRef.current.setRowChildrenExpansion(
+ params.id,
+ !params.rowNode.childrenExpanded,
+ );
+ }
+ };
+
+ apiRef.current.subscribeEvent(
+ GridEvents.rowExpansionChange,
+ handleRowExpansionChange,
+ );
+
+ apiRef.current.subscribeEvent(GridEvents.cellKeyDown, handleCellKeyDown, {
+ isFirst: true,
+ });
+ }, [apiRef]);
+
+ return (
+
+
+
+ );
+}
diff --git a/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.tsx b/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.tsx
new file mode 100644
index 000000000000..6ea54040d6d2
--- /dev/null
+++ b/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.tsx
@@ -0,0 +1,320 @@
+import * as React from 'react';
+import {
+ DataGridPro,
+ getDataGridUtilityClass,
+ GridColumns,
+ DataGridProProps,
+ GridEventListener,
+ GridEvents,
+ GridGroupingColDefOverride,
+ GridRenderCellParams,
+ GridRowModel,
+ GridRowsProp,
+ useGridApiContext,
+ useGridApiRef,
+ useGridRootProps,
+} from '@mui/x-data-grid-pro';
+import { unstable_composeClasses as composeClasses } from '@mui/base';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+
+export const isNavigationKey = (key: string) =>
+ key === 'Home' ||
+ key === 'End' ||
+ key.indexOf('Arrow') === 0 ||
+ key.indexOf('Page') === 0 ||
+ key === ' ';
+
+interface Row {
+ hierarchy: string[];
+ jobTitle: string;
+ recruitmentDate: Date;
+ id: number;
+ descendantCount?: number;
+ childrenFetched?: boolean;
+}
+
+const ALL_ROWS: GridRowModel[] = [
+ {
+ hierarchy: ['Sarah'],
+ jobTitle: 'Head of Human Resources',
+ recruitmentDate: new Date(2020, 8, 12),
+ id: 0,
+ },
+ {
+ hierarchy: ['Thomas'],
+ jobTitle: 'Head of Sales',
+ recruitmentDate: new Date(2017, 3, 4),
+ id: 1,
+ },
+ {
+ hierarchy: ['Thomas', 'Robert'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 11, 20),
+ id: 2,
+ },
+ {
+ hierarchy: ['Thomas', 'Karen'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 10, 14),
+ id: 3,
+ },
+ {
+ hierarchy: ['Thomas', 'Nancy'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2017, 10, 29),
+ id: 4,
+ },
+ {
+ hierarchy: ['Thomas', 'Daniel'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 7, 21),
+ id: 5,
+ },
+ {
+ hierarchy: ['Thomas', 'Christopher'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2020, 7, 20),
+ id: 6,
+ },
+ {
+ hierarchy: ['Thomas', 'Donald'],
+ jobTitle: 'Sales Person',
+ recruitmentDate: new Date(2019, 6, 28),
+ id: 7,
+ },
+ {
+ hierarchy: ['Mary'],
+ jobTitle: 'Head of Engineering',
+ recruitmentDate: new Date(2016, 3, 14),
+ id: 8,
+ },
+ {
+ hierarchy: ['Mary', 'Jennifer'],
+ jobTitle: 'Tech lead front',
+ recruitmentDate: new Date(2016, 5, 17),
+ id: 9,
+ },
+ {
+ hierarchy: ['Mary', 'Jennifer', 'Anna'],
+ jobTitle: 'Front-end developer',
+ recruitmentDate: new Date(2019, 11, 7),
+ id: 10,
+ },
+ {
+ hierarchy: ['Mary', 'Michael'],
+ jobTitle: 'Tech lead devops',
+ recruitmentDate: new Date(2021, 7, 1),
+ id: 11,
+ },
+ {
+ hierarchy: ['Mary', 'Linda'],
+ jobTitle: 'Tech lead back',
+ recruitmentDate: new Date(2017, 0, 12),
+ id: 12,
+ },
+ {
+ hierarchy: ['Mary', 'Linda', 'Elizabeth'],
+ jobTitle: 'Back-end developer',
+ recruitmentDate: new Date(2019, 2, 22),
+ id: 13,
+ },
+ {
+ hierarchy: ['Mary', 'Linda', 'William'],
+ jobTitle: 'Back-end developer',
+ recruitmentDate: new Date(2018, 4, 19),
+ id: 14,
+ },
+];
+
+const columns: GridColumns = [
+ { field: 'jobTitle', headerName: 'Job Title', width: 200 },
+ {
+ field: 'recruitmentDate',
+ headerName: 'Recruitment Date',
+ type: 'date',
+ width: 150,
+ },
+];
+
+const getChildren = (parentPath: string[]) => {
+ const parentPathStr = parentPath.join('-');
+ return ALL_ROWS.filter(
+ (row) => row.hierarchy.slice(0, -1).join('-') === parentPathStr,
+ );
+};
+
+/**
+ * This is a naive implementation with terrible performances on a real dataset.
+ * This fake server is only here for demonstration purposes.
+ */
+const fakeDataFetcher = (parentPath: string[] = []) =>
+ new Promise[]>((resolve) => {
+ setTimeout(() => {
+ const rows = getChildren(parentPath).map((row) => ({
+ ...row,
+ descendantCount: getChildren(row.hierarchy).length,
+ }));
+ resolve(rows);
+ }, 500 + Math.random() * 300);
+ });
+
+const getTreeDataPath = (row) => row.hierarchy;
+
+const useUtilityClasses = (ownerState: { classes: DataGridProProps['classes'] }) => {
+ const { classes } = ownerState;
+
+ const slots = {
+ root: ['treeDataGroupingCell'],
+ toggle: ['treeDataGroupingCellToggle'],
+ };
+
+ return composeClasses(slots, getDataGridUtilityClass, classes);
+};
+
+interface GroupingCellWithLazyLoadingProps extends GridRenderCellParams {
+ hideDescendantCount?: boolean;
+}
+
+/**
+ * Reproduce the behavior of the `GridTreeDataGroupingCell` component in `@mui/x-data-grid-pro`
+ * But base the amount of children on a `row.descendantCount` property rather than on the internal lookups.
+ */
+const GroupingCellWithLazyLoading = (props: GroupingCellWithLazyLoadingProps) => {
+ const { id, field, rowNode, row, hideDescendantCount } = props;
+
+ const rootProps = useGridRootProps();
+ const apiRef = useGridApiContext();
+ const classes = useUtilityClasses({ classes: rootProps.classes });
+
+ const Icon = rowNode.childrenExpanded
+ ? rootProps.components.TreeDataCollapseIcon
+ : rootProps.components.TreeDataExpandIcon;
+
+ const handleKeyDown = (event) => {
+ if (event.key === ' ') {
+ event.stopPropagation();
+ }
+ if (isNavigationKey(event.key) && !event.shiftKey) {
+ apiRef.current.publishEvent(GridEvents.cellNavigationKeyDown, props, event);
+ }
+ };
+
+ const handleClick = (event) => {
+ apiRef.current.setRowChildrenExpansion(id, !rowNode.childrenExpanded);
+ apiRef.current.setCellFocus(id, field);
+ event.stopPropagation();
+ };
+
+ return (
+
+
+ {row.descendantCount > 0 && (
+
+
+
+ )}
+
+
+ {rowNode.groupingKey}
+ {!hideDescendantCount && row.descendantCount > 0
+ ? ` (${row.descendantCount})`
+ : ''}
+
+
+ );
+};
+
+const CUSTOM_GROUPING_COL_DEF: GridGroupingColDefOverride = {
+ renderCell: (params) => ,
+};
+
+export default function TreeDataLazyLoading() {
+ const apiRef = useGridApiRef();
+ const [rows, setRows] = React.useState([]);
+
+ React.useEffect(() => {
+ fakeDataFetcher().then(setRows);
+
+ const handleRowExpansionChange: GridEventListener<
+ GridEvents.rowExpansionChange
+ > = async (node) => {
+ const row = apiRef.current.getRow(node.id) as Row | null;
+
+ if (!node.childrenExpanded || !row || row.childrenFetched) {
+ return;
+ }
+
+ apiRef.current.updateRows([
+ {
+ id: `placeholder-children-${node.id}`,
+ hierarchy: [...row.hierarchy, ''],
+ },
+ ]);
+
+ const childrenRows = await fakeDataFetcher(row.hierarchy);
+ apiRef.current.updateRows([
+ ...childrenRows,
+ { id: node.id, childrenFetched: true },
+ { id: `placeholder-children-${node.id}`, _action: 'delete' },
+ ]);
+
+ if (childrenRows.length) {
+ apiRef.current.setRowChildrenExpansion(node.id, true);
+ }
+ };
+
+ /**
+ * By default, the grid does not toggle the expansion of rows with 0 children
+ * We need to override the `cellKeyDown` event listener to force the expansion if there are children on the server
+ */
+ const handleCellKeyDown: GridEventListener = (
+ params,
+ event,
+ ) => {
+ const cellParams = apiRef.current.getCellParams(params.id, params.field);
+ if (cellParams.colDef.type === 'treeDataGroup' && event.key === ' ') {
+ event.stopPropagation();
+ event.preventDefault();
+ event.defaultMuiPrevented = true;
+
+ apiRef.current.setRowChildrenExpansion(
+ params.id,
+ !params.rowNode.childrenExpanded,
+ );
+ }
+ };
+
+ apiRef.current.subscribeEvent(
+ GridEvents.rowExpansionChange,
+ handleRowExpansionChange,
+ );
+ apiRef.current.subscribeEvent(GridEvents.cellKeyDown, handleCellKeyDown, {
+ isFirst: true,
+ });
+ }, [apiRef]);
+
+ return (
+
+
+
+ );
+}
diff --git a/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.tsx.preview b/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.tsx.preview
new file mode 100644
index 000000000000..b2ebdc84548b
--- /dev/null
+++ b/docs/src/pages/components/data-grid/group-pivot/TreeDataLazyLoading.tsx.preview
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/docs/src/pages/components/data-grid/group-pivot/group-pivot.md b/docs/src/pages/components/data-grid/group-pivot/group-pivot.md
index d76fa6f7bbb0..2fb3c21d71fb 100644
--- a/docs/src/pages/components/data-grid/group-pivot/group-pivot.md
+++ b/docs/src/pages/components/data-grid/group-pivot/group-pivot.md
@@ -124,6 +124,20 @@ You can limit the sorting to the top level rows with the `disableChildrenSorting
> const invalidRows = [{ path: ['A'] }, { path: ['B'] }, { path: ['A', 'A'] }];
> ```
+### Children lazy-loading
+
+> ⚠️ This feature isn't implemented yet. It's coming.
+>
+> 👍 Upvote [issue #3377](https://github.com/mui-org/material-ui-x/issues/3377) if you want to see it land faster.
+
+Alternatively, you can achieve a similar behavior by implementing this feature outside the component as shown bellow.
+This implementation does not support every feature of the grid but can be a good starting point for large datasets.
+
+The idea is to add a property `descendantCount` on the row and to use it instead of the internal grid state.
+To do so, we need to override both the `renderCell` of the grouping column and to manually open the rows by listening to `GridEvents.rowExpansionChange`.
+
+{{"demo": "pages/components/data-grid/group-pivot/TreeDataLazyLoading.js", "bg": "inline", "defaultCodeOpen": false}}
+
### Full example
{{"demo": "pages/components/data-grid/group-pivot/TreeDataFullExample.js", "bg": "inline", "defaultCodeOpen": false}}