Column Rendering

Columns render the field value of the data they are bound to. This is the default behavior, which can be customized in a number of ways that we're exploring below.

Note

If you want to explicitly use the TypeScript type definition for columns, import the InfiniteTableColumn type

import { InfiniteTableColumn } from '@infinite-table/infinite-react'

Note that it's a generic type, so when you use it, you have to bind it to your DATA_TYPE (the type of your data object).

Note

When using custom rendering or custom components for columns, make sure all your rendering logic is controlled and that it doesn't have local/transient state.

This is important because InfiniteTable uses virtualization heavily, in both column cells and column headers, so custom components can and will be unmounted and re-mounted multiple times, during the virtualization process (triggered by user scrolling, sorting, filtering and a few other interactions).

Change the value using valueGetter

The simplest way to change what's being rendered in a column is to use the valueGetter prop and return a new value for the column.

const nameColumn: InfiniteTableColumn<Employee> = {
  header: 'Employee Name',
  valueGetter: ({ data }) => `${data.firstName} ${data.lastName}`,
};

Note

The columns.valueGetter prop is a function that takes a single argument - an object with data and field properties.

Note that the columns.valueGetter is only called for non-group rows, so the data property is of type DATA_TYPE.

Column with custom valueGetter
View Mode
Fork
import { InfiniteTable, DataSource } from '@infinite-table/infinite-react';
import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react';
import * as React from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', defaultWidth: 80 },
  name: {
    header: 'Full Name',
    valueGetter: ({ data }) => `${data.firstName} ${data.lastName}`,
  },

  preferredLanguage: { field: 'preferredLanguage' },
  stack: { field: 'stack' },
};

export default function ColumnValueGetterExample() {
  return (
    <>
      <DataSource<Developer> primaryKey="id" data={dataSource}>
        <InfiniteTable<Developer> columns={columns} columnDefaultWidth={200} />
      </DataSource>
    </>

Note

The column value getter should not return JSX or other markup, because the value return by columns.valueGetter will be used when the column is sorted (when sorting is done client-side and not remotely). For more in-depth information on sorting see the column sorting page.

Use renderValue and render to display custom content

The next step in customizing the rendering for a column is to use the columns.renderValue or the columns.render props. In those functions, you have access to more information than in the columns.valueGetter function. For example, you have access to the current value of groupBy and pivotBy props.

renderValue and render can return any value that React can render.

The renderValue and render functions are called with an object that has the following properties:

  • data - the data object (of type DATA_TYPE | Partial<DATA_TYPE> | null) for the row.
  • rowInfo - very useful information about the current row:
    • rowInfo.collapsed - if the row is collased or not.
    • rowInfo.groupBy - the current group by for the row
    • rowInfo.indexInAll - the index of the row in the whole data set
    • rowInfo.indexInGroup - the index of the row in the current group
    • rowInfo.value - the value (only for group rows) that will be rendered by default in group column cells.
    • ... there are other useful properties that we'll document in the near future
  • column - the current column being rendered
  • columnsMap - the Map of columns available to the table. Note these might not be all visible. The keys in this map will be column ids.
  • fieldsToColumn a Map that links DataSource fields to columns. Columns bound to fields (so with columns.field specified) will be included in this Map.
  • api - A reference to the Infinite Table API object.
Deep Dive

Column renderValue vs render

Note

Inside the columns.renderValue and columns.render functions (and other rendering functions), you can use the useInfiniteColumnCell hook to retrieve the same params that are passed to the render functions.

This is especially useful when inside those functions you render a custom component that needs access to the same information.

type Developer = { country: string; name: string; id: string };

const CountryInfo = () => {
  const { data, rowInfo, value } = useInfiniteColumnCell<Developer>();

  return <div>Country: {value}</div>;
};

const columns = {
  country: {
    field: 'country',
    renderValue: () => <CountryInfo />,
  },
};
Column with custom renderValue
View Mode
Fork
import {
  InfiniteTable,
  DataSource,
  DataSourceGroupBy,
  InfiniteTablePropGroupColumn,
  InfiniteTableColumnRenderValueParam,
} from '@infinite-table/infinite-react';
import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react';
import * as React from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', defaultWidth: 80 },
  stack: {
    field: 'stack',
    renderValue: ({ data, rowInfo }) => {
      if (rowInfo.isGroupRow) {
        return <>{rowInfo.value} stuff</>;
      }

      return <b>🎇 {data?.stack}</b>;
    },
  },
  firstName: {
    field: 'firstName',
  },

  preferredLanguage: { field: 'preferredLanguage' },
};

const defaultGroupBy: DataSourceGroupBy<Developer>[] = [{ field: 'stack' }];

const groupColumn: InfiniteTablePropGroupColumn<Developer> = {
  defaultWidth: 250,

  renderValue: ({
    rowInfo,
  }: InfiniteTableColumnRenderValueParam<Developer>) => {
    if (rowInfo.isGroupRow) {
      return (
        <>
          Grouped by <b>{rowInfo.value}</b>
        </>
      );
    }
    return null;
  },
};

export default function ColumnValueGetterExample() {
  return (
    <>
      <DataSource<Developer>
        primaryKey="id"
        data={dataSource}
        defaultGroupBy={defaultGroupBy}
      >
        <InfiniteTable<Developer>
          groupColumn={groupColumn}
          columns={columns}
          columnDefaultWidth={200}
        />
      </DataSource>
    </>

Changing the group icon using render. The icon can also be changed using columns.renderGroupIcon.

Column with render - custom expand/collapse icon

This snippet shows overriding the group collapse/expand tool via the columns.render function.

View Mode
Fork
import {
  InfiniteTable,
  DataSource,
  DataSourceGroupBy,
  InfiniteTablePropGroupColumn,
} from '@infinite-table/infinite-react';
import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react';
import * as React from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', defaultWidth: 80 },
  stack: {
    field: 'stack',
  },
  firstName: {
    field: 'firstName',
  },
  preferredLanguage: { field: 'preferredLanguage' },
};

const defaultGroupBy: DataSourceGroupBy<Developer>[] = [{ field: 'stack' }];

const groupColumn: InfiniteTablePropGroupColumn<Developer> = {
  defaultWidth: 250,
  render: ({ rowInfo, toggleCurrentGroupRow }) => {
    if (rowInfo.isGroupRow) {
      const { collapsed } = rowInfo;
      const expandIcon = (
        <svg
          style={{
            display: 'inline-block',
            fill: collapsed ? '#b00000' : 'blue',
          }}
          width="20px"
          height="20px"
          viewBox="0 0 24 24"
          fill="#000000"
        >
          {collapsed ? (
            <>
              <path d="M0 0h24v24H0V0z" fill="none" />
              <path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z" />
            </>
          ) : (
            <path d="M7.41 18.59L8.83 20 12 16.83 15.17 20l1.41-1.41L12 14l-4.59 4.59zm9.18-13.18L15.17 4 12 7.17 8.83 4 7.41 5.41 12 10l4.59-4.59z" />
          )}
        </svg>
      );
      return (
        <div
          style={{
            cursor: 'pointer',
            display: 'flex',
            alignItems: 'center',
            color: collapsed ? '#b00000' : 'blue',
          }}
          onClick={() => toggleCurrentGroupRow()}
        >
          <i style={{ marginRight: 5 }}>Grouped by</i> <b>{rowInfo.value}</b>
          {expandIcon}
        </div>
      );
    }
    return null;
  },
};

export default function ColumnCustomRenderExample() {
  return (
    <>
      <DataSource<Developer>
        primaryKey="id"
        data={dataSource}
        defaultGroupBy={defaultGroupBy}
      >
        <InfiniteTable<Developer>
          groupColumn={groupColumn}
          columns={columns}
          columnDefaultWidth={200}
        />
      </DataSource>
    </>

Column with custom expand/collapse tool via renderGroupIcon

This snippet shows how you can override the group collapse/expand tool via the columns.renderGroupIcon function.

View Mode
Fork
import {
  InfiniteTable,
  DataSource,
  DataSourcePropGroupBy,
  InfiniteTablePropColumns,
  InfiniteTableColumn,
} from '@infinite-table/infinite-react';
import * as React from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', defaultWidth: 80 },
  stack: {
    field: 'stack',
  },
  firstName: {
    field: 'firstName',
  },

  preferredLanguage: { field: 'preferredLanguage' },
};
const groupBy: DataSourcePropGroupBy<Developer> = [
  {
    field: 'country',
    column: {
      header: 'Country group',
      renderGroupValue: ({ value }) => <>Country: {value}</>,
    },
  },
  { field: 'preferredLanguage' },
];

const groupColumn: InfiniteTableColumn<Developer> = {
  renderGroupIcon: ({ rowInfo, toggleCurrentGroupRow }) => {
    return (
      <div
        onClick={toggleCurrentGroupRow}
        style={{ cursor: 'pointer', marginRight: 10 }}
      >
        {rowInfo.isGroupRow ? (rowInfo.collapsed ? '👇' : '👉') : ''}
      </div>
    );
  },
};

export default function App() {
  return (
    <DataSource<Developer> data={dataSource} primaryKey="id" groupBy={groupBy}>
      <InfiniteTable<Developer> columns={columns} groupColumn={groupColumn} />
    </DataSource>

Using hooks for custom rendering

Inside the columns.render and columns.renderValue functions, you can use hooks - both provided by InfiniteTable and any other React hooks.

Hook: useInfiniteColumnCell

When you're inside a rendering function for a column cell, you can use useInfiniteColumnCell hook to get access to the current cell's rendering information - the argument passed to the render or renderValue functions.

import {
  useInfiniteColumnCell,
  InfiniteTableColumn,
} from '@infinite-table/infintie-react';

function CustomName() {
  const { data, rowInfo } = useInfiniteColumnCell<Employee>();

  return (
    <>
      <b>{data.firstName}</b>, {data.lastName}
    </>
  );
}

const nameColumn: InfiniteTableColumn<Employee> = {
  header: 'Employee Name',
  renderValue: () => <CustomName />,
};
Column with render & useInfiniteColumnCell
View Mode
Fork
import {
  InfiniteTable,
  DataSource,
  useInfiniteColumnCell,
} from '@infinite-table/infinite-react';
import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react';
import * as React from 'react';
import { HTMLProps } from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

function CustomCell(_props: HTMLProps<HTMLElement>) {
  const { value, data } = useInfiniteColumnCell<Developer>();

  let emoji = '🤷';
  switch (value) {
    case 'photography':
      emoji = '📸';
      break;
    case 'cooking':
      emoji = '👨🏻‍🍳';
      break;
    case 'dancing':
      emoji = '💃';
      break;
    case 'reading':
      emoji = '📚';
      break;
    case 'sports':
      emoji = '⛹️';
      break;
  }

  const label = data?.stack === 'frontend' ? '⚛️' : '';

  return (
    <b>
      {emoji} + {label}
    </b>
  );
}

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', maxWidth: 80 },
  firstName: { field: 'firstName' },
  hobby: {
    field: 'hobby',
    // we're not using the arg of the render function directly
    // but CustomCell uses `useInfiniteColumnCell` to retrieve it instead
    render: () => <CustomCell />,
  },
};

export default function ColumnRenderWithHooksExample() {
  return (
    <>
      <DataSource<Developer> primaryKey="id" data={dataSource}>
        <InfiniteTable<Developer> columns={columns} columnDefaultWidth={200} />
      </DataSource>
    </>

Hook: useInfiniteHeaderCell

For column headers, you can use useInfiniteHeaderCell hook to get access to the current header's rendering information - the argument passed to the columns.header function.

import {
  useInfiniteHeaderCell,
  InfiniteTableColumn,
} from '@infinite-table/infintie-react';

function CustomHeader() {
  const { column } = useInfiniteHeaderCell<Employee>();

  return <b>{column.field}</b>;
}

const nameColumn: InfiniteTableColumn<Employee> = {
  header: 'Employee Name',
  field: 'firstName',
  header: () => <CustomHeader />,
};
Column Header with render & useInfiniteHeaderCell
View Mode
Fork
import {
  InfiniteTable,
  DataSource,
  useInfiniteHeaderCell,
} from '@infinite-table/infinite-react';
import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react';
import * as React from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

const HobbyHeader: React.FC = function () {
  const { column } = useInfiniteHeaderCell<Developer>();

  return <b style={{ color: '#0000c2' }}>{column?.field} 🤷📸👨🏻‍🍳💃📚⛹️</b>;
};

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', maxWidth: 80 },
  stack: {
    field: 'stack',
  },
  hobby: {
    field: 'hobby',
    header: HobbyHeader,
  },
};

export default function ColumnHeaderExampleWithHooks() {
  return (
    <>
      <DataSource<Developer> primaryKey="id" data={dataSource}>
        <InfiniteTable<Developer> columns={columns} columnDefaultWidth={200} />
      </DataSource>
    </>

Use column.components to customize the column

There are cases when custom rendering via the columns.render and columns.renderValue props is not enough and you want to fully control the column cell and render your own custom component for that.

For such scenarios, you can specify column.components.HeaderCell and column.components.ColumnCell, which will use those components to render the DOM nodes of the column header and column cells respectively.

import { InfiniteTableColumn } from '@infinite-table/infintie-react';

const ColumnCell = (props: React.HTMLProps<HTMLDivElement>) => {
  const { domRef, rowInfo } = useInfiniteColumnCell<Developer>();

  return (
    <div ref={domRef} {...props} style={{ ...props.style, color: 'red' }}>
      {props.children}
    </div>
  );
};

const HeaderCell = (props: React.HTMLProps<HTMLDivElement>) => {
  const { domRef, sortTool } = useInfiniteHeaderCell<Developer>();

  return (
    <div ref={domRef} {...props} style={{ ...props.style, color: 'red' }}>
      {sortTool}
      First name
    </div>
  );
};

const nameColumn: InfiniteTableColumn<Developer> = {
  header: 'Name',
  field: 'firstName',
  components: {
    ColumnCell,
    HeaderCell,
  },
};

Note

When using custom components, make sure you get domRef from the corresponding hook (useInfiniteColumnCell for column cells and useInfiniteHeaderCell for header cells) and pass it on to the final JSX.Element that is the DOM root of the component.

// inside a component specified in column.components.ColumnCell
const { domRef } = useInfiniteColumnCell<DATA_TYPE>();

return <div ref={domRef}>...</div>;

Also you have to make sure you spread all other props you receive in the component, as they are HTMLProps that need to end-up in the DOM (eg: className for theming and default styles, etc).

Both components.ColumnCell and components.HeaderCell need to be declared with props being of type HTMLProps<HTMLDivElement>.

Custom components
View Mode
Fork
import {
  InfiniteTable,
  DataSource,
  useInfiniteColumnCell,
  useInfiniteHeaderCell,
  InfiniteTablePropColumnTypes,
} from '@infinite-table/infinite-react';
import type { InfiniteTablePropColumns } from '@infinite-table/infinite-react';
import * as React from 'react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;
  country: string;
  city: string;
  currency: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';
  hobby: string;
  salary: number;
  age: number;
};

const dataSource = () => {
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + '/developers1k')
    .then((r) => r.json())
    .then((data: Developer[]) => data);
};

const DefaultHeaderComponent: React.FunctionComponent<
  React.HTMLProps<HTMLDivElement>
> = (props) => {
  const { column, domRef, columnSortInfo } = useInfiniteHeaderCell<Developer>();

  const style = {
    ...props.style,
    border: '1px solid #fefefe',
  };

  let sortTool = '';
  switch (columnSortInfo?.dir) {
    case undefined:
      sortTool = '👉';
      break;
    case 1:
      sortTool = '👇';
      break;
    case -1:
      sortTool = '☝🏽';
      break;
  }

  return (
    <div ref={domRef} {...props} style={style}>
      {/* here you would usually have: */}
      {/* {props.children} {sortTool} */}
      {/* but in this case we want to override the default sort tool as well (which is part of props.children) */}
      {column.field} {sortTool}
    </div>
  );
};

const StackComponent: React.FunctionComponent<
  React.HTMLProps<HTMLDivElement>
> = (props) => {
  const { value, domRef } = useInfiniteColumnCell<Developer>();

  const isFrontEnd = value === 'frontend';
  const emoji = isFrontEnd ? '⚛️' : '💽';
  const style = {
    padding: '5px 20px',
    border: `1px solid ${isFrontEnd ? 'red' : 'green'}`,
    ...props.style,
  };
  return (
    <div ref={domRef} {...props} style={style}>
      {props.children} <div style={{ flex: 1 }} /> {emoji}
    </div>
  );
};

const columnTypes: InfiniteTablePropColumnTypes<Developer> = {
  default: {
    // override all columns to use these components
    components: {
      HeaderCell: DefaultHeaderComponent,
    },
  },
};

const columns: InfiniteTablePropColumns<Developer> = {
  id: { field: 'id', defaultWidth: 80 },
  stack: {
    field: 'stack',
    renderValue: ({ data }) => 'Stack: ' + data?.stack,
    components: {
      HeaderCell: DefaultHeaderComponent,
      ColumnCell: StackComponent,
    },
  },
  firstName: {
    field: 'firstName',
  },
  preferredLanguage: {
    field: 'preferredLanguage',
  },
};

export default function ColumnValueGetterExample() {
  return (
    <>
      <DataSource<Developer> primaryKey="id" data={dataSource}>
        <InfiniteTable<Developer> columns={columns} columnTypes={columnTypes} />
      </DataSource>
    </>

Note

If you're using the useInfiniteColumnCell hook inside the columns.render or columns.renderValue functions (and not as part of a custom component in columns.components.ColumnCell), you don't need to pass on the domRef to the root of the DOM you're rendering (same is true if you're using useInfiniteHeaderCell inside the columns.header function).

If the above columns.components is still not enough, read about the rendering pipeline below.

Rendering pipeline

The rendering pipeline for columns is a series of functions defined on the column that are called while rendering.

Note

All the functions that have the word render in their name will be called with an object that has a renderBag property, which contains values that will be rendered.

The default columns.render function (the last one in the pipeline) ends up rendering a few things:

  • a value - generally comes from the field the column is bound to
  • a groupIcon - for group columns
  • a selectionCheckBox - for columns that have columns.renderSelectionCheckBox defined (combined with row selection)

When the rendering process starts for a column cell, all the above end up in the renderBag object.

Rendering pipeline - renderBag.value

As already mentioned, the value defaults to the value of the column field for the current row.

If the column is not bound to a field, you can define a valueGetter. The valueGetter only has access to {data, field?} in order to compute a value and return it.

After the valueGetter is called, the valueFormatter is next in the rendering pipeline.

This is called with more details about the current cell

const column: InfiniteTableColumn<T> = {
  // the valueGetter can be useful when rows are nested objects
  // or you want to compose multiple values from the row
  valueGetter: ({ data }) => {
    return data.person.salary * 10;
  },
  valueFormatter: ({
    value,
    isGroupRow,
    data,
    field,
    rowInfo,
    rowSelected,
    rowActive,
  }) => {
    // the value here is what the `valueFormatter` returned
    return `USD ${value}`;
  },
};

After valueGetter and valueFormatter are called, the resulting value is the actual value used for the cell. This value will also be assigned to renderBag.value

When renderValue and render are called by InfiniteTable, both value and renderBag will be available as properties to the arguments object.

const column: InfiniteTableColumn<T> = {
  valueGetter: () => 'world',
  renderValue: ({ value, renderBag, rowInfo }) => {
    // at this stage, `value` is 'world' and `renderBag.value` has the same value, 'world'
    return <b>{value}</b>;
  },

  render: ({ value, renderBag, rowInfo }) => {
    // at this stage `value` is 'world'
    // but `renderBag.value` is <b>world</b>, as this was the value returned by `renderValue`
    return <div>Hello {renderBag.value}!</div>;
  },
};

Note

After the renderValue function is called, the following are also called (if available):

You can think of them as an equivalent to renderValue, but narrowed down to group/non-group rows.

Inside those functions, the renderBag.value refers to the value returned by the renderValue function.

Rendering pipeline - renderBag.groupIcon

In a similar way to renderBag.value, the renderBag.groupIcon is also piped through to the render function.

const column: InfiniteTableColumn<T> = {
  renderGroupIcon: ({ renderBag, toggleGroupRow }) => {
    return <> [ {renderBag.groupIcon} ] </>;
  },
  render: ({ renderBag }) => {
    return (
      <>
        {/* use the groupIcon from the renderBag */}
        {renderBag.groupIcon}
        {renderBag.value}
      </>
    );
  },
};

Hint

Inside columns.renderGroupIcon, you have access to renderBag.groupIcon, which is basically the default group icon - so you can use that if you want, and build on that.

Also inside columns.renderGroupIcon, you have access to toggleGroupRow so you can properly hook the collapse/expand behaviour to your custom group icon.

Rendering pipeline - renderBag.selectionCheckBox

Like with the previous properties of renderBag, you can customize the selectionCheckBox (used when multiple selection is configured) to be piped-through - for columns that specify columns.renderSelectionCheckBox.

const column: InfiniteTableColumn<T> = {
  renderSelectionCheckBox: ({
    renderBag,
    rowSelected,
    isGroupRow,
    toggleCurrentRowSelection,
    toggleCurrentGroupRowSelection,
  }) => {
    const toggle = isGroupRow
      ? toggleCurrentGroupRowSelection
      : toggleCurrentRowSelection;

    // you could return renderBag.groupIcon to have the default icon

    const selection =
      rowSelected === null
        ? '-' // we're in a group row with indeterminate state if rowSelected === null
        : rowSelected
        ? 'x'
        : 'o';

    return <div onClick={toggle}> [ {selection} ] </div>;
  },
  render: ({ renderBag }) => {
    return (
      <>
        {/* use the selectionCheckBox from the renderBag */}
        {renderBag.selectionCheckBox}
        {renderBag.groupIcon}
        {renderBag.value}
      </>
    );
  },
};

To recap, here is the full list of the functions in the rendering pipeline, in order of invocation:

  1. columns.valueGetter - doesn't have access to renderBag
  2. columns.valueFormatter - doesn't have access to renderBag
  3. columns.renderGroupIcon - can use all properties in renderBag
  4. columns.renderSelectionCheckBox - can use all properties in renderBag
  5. columns.renderValue - can use all properties in renderBag
  6. columns.renderGroupValue - can use all properties in renderBag
  7. columns.renderLeafValue - can use all properties in renderBag
  8. columns.render - can use all properties in renderBag

Additionally, the columns.components.ColumnCell custom component does have access to the renderBag via useInfiniteColumnCell