DataTable TanStack Table v9 codemod

DataTable is built on TanStack Table v9 in v3. This codemod migrates the four mechanical parts of that move. Two of them are Ember-only, because Pluma's hand-rolled Ember integration was replaced by TanStack's own @tanstack/ember-table.

What it changes

Ember only — flexRenderWrapperflexRenderComponent

// Before
import { flexRenderWrapper } from '@customerio/pluma-components/ember/tanstack/table';

const columns = [
	columnHelper.accessor('name', {
		header: 'Name',
		cell: (info) => flexRenderWrapper(NameCell, { name: info.getValue() }),
	}),
];

interface NameCellSignature {
	Args: {
		props: { name: string };
	};
}

const NameCell: TOC<NameCellSignature> = <template>{{@props.name}}</template>;
// After
import { flexRenderComponent } from '@tanstack/ember-table';

const columns = [
	columnHelper.accessor('name', {
		header: 'Name',
		cell: (info) => flexRenderComponent(NameCell, { name: info.getValue() }),
	}),
];

interface NameCellSignature {
	Args: {
		options: { name: string };
	};
}

const NameCell: TOC<NameCellSignature> = <template>{{@options.name}}</template>;

The @customerio/pluma-components/ember/tanstack/table entry point is deleted in v3. Three things change together:

  1. the import moves to @tanstack/ember-table
  2. flexRenderWrapper(…) becomes flexRenderComponent(…)
  3. the rendered component's arguments change — it used to get the custom object as @props, and now gets the cell/header context as @ctx plus the custom object as @options

Step 3 is the one that needs judgement, so it's kept narrow. For every component passed as the first argument to a flexRenderWrapper/flexRenderComponent call, if that component is declared in the same file the codemod rewrites, inside that declaration only:

  • @props@options (in the <template>)
  • this.args.propsthis.args.options (in the JS region)
  • the props key under Args in the component's signature type — both the inline (TOC<{ Args: { props: … } }>) and named (Component<NameCellSignature>, declared in the same file) shapes

@props elsewhere in the file is never touched, so a cell component sitting next to unrelated components is safe.

Both frameworks — sortingFnsortFn

v9 renamed the column-definition option. Leaving it silently disables sorting for that column rather than erroring, which is why this one is worth automating.

It's only renamed when the object literal is provably a column definition: it carries one of accessorKey, accessorFn, header, cell or id, or it's an argument to columnHelper.accessor(…) / .display(…) / .group(…). Anything else is left alone and listed for manual review — including a sortingFn nested in meta.

Both frameworks — table.getState()table.store.state

getState() is gone in v9. To avoid rewriting an unrelated getState() (a zustand store, a state machine, your own class), only receivers that read like a table are touched: the identifier table, dataTable or tableInstance, or any identifier/member ending in table (case-insensitive) — usersTable, this.args.dataTable, peopleTable. Anything else is left alone and listed.

Every rewrite is reported, because store.state is a live snapshot object rather than a call: code that captured const state = table.getState() and expected it to stay frozen behaves differently.

Both frameworks — the createColumnHelper features type parameter

// Before
const columnHelper = createColumnHelper<Person>();
// After
import type { DataTableFeatures } from '@customerio/pluma-components/react';
const columnHelper = createColumnHelper<DataTableFeatures, Person>();

v9 added a leading features type parameter. The codemod only fires when the call has exactly one type argument and createColumnHelper is imported from @tanstack/table-core, @tanstack/react-table or @tanstack/ember-table (the adapters re-export table-core wholesale). Two type arguments means it's already migrated.

DataTableFeatures is folded into an existing @customerio/pluma-components/{react,ember} import when the file has one (import { DataTable, type DataTableFeatures } from …), otherwise a dedicated import type line is added.

Not migrated — do these by hand

  • meta.getCellColSpan. Deprecated, but still works: it's mapped onto v9's spanColumns for you. The callback's signature changed (it took the cell, and spanColumns takes { row, column, table }) and "no span" changed from undefined to 1, so a mechanical rewrite would be wrong. Migrate it when you touch the column, and you also get Infinity support in exchange.
  • The other TanStack types that gained the features parameter. Table, Row, Cell, Column and ColumnDef all take it now (Table<DataTableFeatures, Person>). Only createColumnHelper is automated, because it's the one that's always called the same way; the types appear in aliases, generics and prop signatures where an insertion isn't safely mechanical. TypeScript will point at every one of them.
  • FlexRender (Ember). It split into FlexRenderCell, FlexRenderHeader and FlexRenderFooter, each taking the cell/header/footer directly. There's no 1:1 replacement, so the codemod reports it instead of guessing.
  • Cell components in another module (Ember). See the note below.

Running it

Run it through the Pluma CLI from inside your consuming app. Always start with a dry run to review the diff, the warnings and the review notes before applying:

# from the root of your consuming app

# Dry run first (no writes; per-file diff + warnings + review notes)
pnx @customerio/pluma-cli@latest upgrade --codemod data-table-tanstack-v9 react --dry ./src
pnx @customerio/pluma-cli@latest upgrade --codemod data-table-tanstack-v9 ember --dry ./app

# Then apply for real
pnx @customerio/pluma-cli@latest upgrade --codemod data-table-tanstack-v9 react ./src
pnx @customerio/pluma-cli@latest upgrade --codemod data-table-tanstack-v9 ember ./app

Both frameworks accept --dry / -d. The React path also forwards standard jscodeshift flags.

The Ember transform walks .gts, .gjs, .ts and .js. .hbs is skipped — none of these APIs appear in a template-only file, and a classic component's @props can't be migrated without its backing class (see below).

What gets reported instead of changed

Everything below is left exactly as it was and printed in the "needs manual review" summary with a file:line:

table
SituationWhy
The deleted entry point is imported more than once, or exports something other than flexRenderWrapperThe flexRender migration is skipped for that file - import and call sites alike - so it stays consistent. The independent renames still apply
The cell component is imported from another moduleIts @props are in a file we can't tie back to this call
The cell component is declared in a .ts/.js fileIts template is a separate .hbs — only half the rename would land
The first argument isn't a plain identifier (flexRenderWrapper(row.cell, …))We can't tell which component to rewrite
The component is also invoked directly with @props=Renaming its argument would break that call site
The component destructures let { props } = this.argsThe argument name isn't spelled out at the rewrite site
A signature shared with a component that isn't being migratedRenaming Args.props for one of them would break the other's @props
The component reads .props off something else (a constructor's args)Only @props and this.args.props are rewritten; the rest is by hand
The deleted entry point is re-exported (export … from …)A re-export isn't rewritten — repoint the barrel by hand
flexRenderWrapper is also bound locally (a parameter, a variable)Not every call by that name is the import's
sortingFn in an object we can't prove is a column def, or next to an existing sortFnGuessing here would either miss sorting or duplicate a key
getState() on a receiver that doesn't read like a tableIt's more likely someone else's getState
createColumnHelper() with no type argumentThere's nothing to put the features parameter in front of
createColumnHelper in a file that declares its own DataTableFeaturesThe import the migration needs would redeclare that name
A file that fails to parseNever worth risking a mangled file

Sites that were changed but deserve a look (each migrated cell component, and every getState() rewrite) are printed in the review-notes section instead.

Working on the codemod itself

Both transforms compute text edits against the original source rather than reprinting an AST. That's what keeps a 700-line column-definition file's diff down to the lines that actually changed, and it's the only way to edit a .gts file, whose <template> regions no JS parser can read.

For .gts/.gjs, content-tag locates the template regions and each one is blanked in place — same character count, same newlines, with a valid stand-in (_pt0; for a class-member template, 0 for an expression one) at the front. The remainder parses as ordinary TS, and because nothing shifted, an AST offset in the masked source is the same offset in the real file.

One parser gotcha: jscodeshift hands transforms a recast-wrapped AST whose start/end/loc.column are measured in tab-expanded columns, so a tab-indented file would put every edit in the wrong place. Both transforms parse with jscodeshift's underlying babel parser directly (jscodeshift/parser/tsx) and walk the tree with a small type index in lib/table-edits.cjs.

pnpm --filter @customerio/pluma-cli run test:transforms

Tests use Node's built-in test runner (node --test).