Skip to content

Component Columns

When the built-in column types and display modifiers don't fit your needs, you can delegate cell rendering to your own frontend component. The column definition stays in PHP - only the rendering is custom.

PHP Setup

Use ComponentColumn::create() or the ->component() modifier on any column:

php
use Forjed\InertiaTable\Columns\ComponentColumn;

// Typed column - third argument is the component name
ComponentColumn::create('status', 'Status', 'StatusIndicator')

// Or as a display modifier on a base column
Column::make('status', 'Status')->component('StatusIndicator')

The first argument is the column name (data key), the second is the header, and the third is the name you register the component under on the frontend.

Registering Components

Register your component at app boot:

tsx
// app.tsx
import { registerCellComponent } from 'inertia-table-react';

registerCellComponent('StatusIndicator', ({ value }) => (
    <div className="flex items-center gap-2">
        <div className={`h-2 w-2 rounded-full ${value === 'active' ? 'bg-green-500' : 'bg-gray-300'}`} />
        <span>{String(value)}</span>
    </div>
));
ts
// app.ts
import { registerCellComponent } from 'inertia-table-vue';
import { h } from 'vue';

registerCellComponent('StatusIndicator', ({ value }) =>
    h('div', { class: 'flex items-center gap-2' }, [
        h('div', { class: `h-2 w-2 rounded-full ${value === 'active' ? 'bg-green-500' : 'bg-gray-300'}` }),
        h('span', String(value)),
    ])
);

Component Props

Every custom component receives three props:

tsx
interface CellComponentProps {
    row: Row;       // full row data including hidden columns
    value: unknown; // the column's resolved value
    column: string; // the column name this component is bound to
}

Use value for the bound column data and row when you need to access other fields. The column prop tells you which field the component is rendering, making components reusable across different columns.

Reusable Components

Because components receive value instead of reading a hardcoded field, the same component works on any column:

php
// PHP - same component, different columns
ComponentColumn::create('status', 'Server Status', 'StatusIndicator')
ComponentColumn::create('health', 'Health Check', 'StatusIndicator')
ComponentColumn::create('connectivity', 'Network', 'StatusIndicator')

The StatusIndicator component doesn't need to know which field it's bound to - it just uses value.

Use row to access hidden data columns or other fields alongside the bound value:

php
// PHP
protected function columns(): array
{
    return [
        ComponentColumn::create('progress', 'Progress', 'ProgressBar'),
        Column::data('target'),   // hidden - accessible via row.target
        Column::data('id'),
    ];
}
tsx
registerCellComponent('ProgressBar', ({ value, row }) => {
    const current = Number(value);
    const target = Number(row.target);
    const percent = target > 0 ? Math.round((current / target) * 100) : 0;

    return (
        <div className="flex items-center gap-2">
            <div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
                <div
                    className="h-2 rounded-full bg-blue-500"
                    style={{ width: `${percent}%` }}
                />
            </div>
            <span className="text-xs text-gray-500 whitespace-nowrap">{percent}%</span>
        </div>
    );
});
ts
// app.ts
import { registerCellComponent } from 'inertia-table-vue';
import { h } from 'vue';

registerCellComponent('ProgressBar', ({ value, row }) => {
    const current = Number(value);
    const target = Number(row.target);
    const percent = target > 0 ? Math.round((current / target) * 100) : 0;

    return h('div', { class: 'flex items-center gap-2' }, [
        h('div', { class: 'h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700' }, [
            h('div', {
                class: 'h-2 rounded-full bg-blue-500',
                style: { width: `${percent}%` },
            }),
        ]),
        h('span', { class: 'text-xs text-gray-500 whitespace-nowrap' }, `${percent}%`),
    ]);
});

TypeScript Typing

Use CellComponentProps for type safety:

tsx
import type { CellComponentProps } from 'inertia-table-react';

interface ServerRow {
    id: number;
    status: string;
    uptime: number;
}

function StatusIndicator({ value, row }: CellComponentProps<ServerRow>) {
    // value is typed as unknown - cast as needed
    const status = String(value);
    // row is typed as ServerRow
    const uptime = row.uptime;

    return (
        <div className="flex items-center gap-2">
            <div className={`h-2 w-2 rounded-full ${status === 'active' ? 'bg-green-500' : 'bg-red-500'}`} />
            <span>{status}</span>
            <span className="text-xs text-gray-400">({uptime}h)</span>
        </div>
    );
}

registerCellComponent('StatusIndicator', StatusIndicator);
ts
// app.ts
import { registerCellComponent } from 'inertia-table-vue';
import { defineComponent, h } from 'vue';
import type { CellComponentProps } from 'inertia-table-vue';

interface ServerRow {
    id: number;
    status: string;
    uptime: number;
}

const StatusIndicator = defineComponent({
    props: {
        value: { type: null, required: true },
        row: { type: Object as () => ServerRow, required: true },
        column: { type: String, required: true },
    },
    setup(props) {
        return () => {
            const status = String(props.value);
            const uptime = props.row.uptime;

            return h('div', { class: 'flex items-center gap-2' }, [
                h('div', { class: `h-2 w-2 rounded-full ${status === 'active' ? 'bg-green-500' : 'bg-red-500'}` }),
                h('span', status),
                h('span', { class: 'text-xs text-gray-400' }, `(${uptime}h)`),
            ]);
        };
    },
});

registerCellComponent('StatusIndicator', StatusIndicator);

Inline Alternative

If you don't need a reusable component, you can define custom rendering inline without registration - use the cellRenderers prop in React or #cell-{columnName} scoped slots in Vue. Use a standard column in PHP:

php
Column::make('status', 'Status')->sortable()

Then render it on the frontend:

tsx
<InertiaTable
    tableData={servers}
    cellRenderers={{
        status: ({ value, row }) => (
            <div className="flex items-center gap-2">
                <div className={`h-2 w-2 rounded-full ${value === 'active' ? 'bg-green-500' : 'bg-gray-300'}`} />
                <span>{String(value)}</span>
            </div>
        ),
    }}
/>
vue
<template>
    <InertiaTable :table-data="servers">
        <template #cell-status="{ value, row }">
            <div class="flex items-center gap-2">
                <div :class="`h-2 w-2 rounded-full ${value === 'active' ? 'bg-green-500' : 'bg-gray-300'}`" />
                <span>{{ String(value) }}</span>
            </div>
        </template>
    </InertiaTable>
</template>

In React, the key in cellRenderers matches the column name from PHP. In Vue, use the #cell-{columnName} scoped slot. Both receive { row, value, column, displays, rowIndex } - see Overriding Cells for the full type.

When to use which:

  • ComponentColumn + registerCellComponent - reusable across tables, defined once at boot
  • cellRenderers (React) / #cell-{columnName} slots (Vue) - one-off, page-specific, no registration needed

Combining with Other Modifiers

ComponentColumn supports the same fluent methods as other columns:

php
ComponentColumn::create('status', 'Status', 'StatusIndicator')
    ->sortable()
    ->fit()

Note that ->component() as a display modifier replaces the entire cell rendering - other display modifiers like ->text() or ->badge() on the same column are ignored when a component display is present.