PrimeReact 11 replaced configuration props with composition. A Dropdown that
was one tag is now a Select with a root, a trigger, a value, an indicator, a
portal, a positioner, a popup and a list.
That is better, and it is also six times more markup at every call site. So if you have three hundred dropdowns in an application, seeing the new markup once in a migration guide does not help you much: the work is in the three hundred call sites, and nobody is going to hand-edit those. You write the composition once, in a file of your own, and then the call sites go back to looking roughly like the ones you already have.
What the props became
In v10 a component absorbed every feature as a prop: DataTable had around 149 of them, Calendar around 100, and Dropdown around 70. Anything the props did not cover was out of reach.
// v10
<Dropdown value={city} options={cities} optionLabel="name" placeholder="Select a City" onChange={(e) => setCity(e.value)} />
// v11
<Select.Root value={city} options={cities} optionLabel="name" onValueChange={(e) => setCity(e.value)}>
<Select.Trigger>
<Select.Value placeholder="Select a City" />
<Select.Indicator>
<ChevronDown />
</Select.Indicator>
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.List />
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
The second version is longer because things that used to be unreachable are now
written out. panelClassName, dropdownIcon and itemTemplate were the only
ways in, and now every one of those positions is an element that takes props,
refs and children.
The requests that had no clean answer in v10 fall out of that. A sticky footer
in the panel that you can measure with a ref, a tooltip on one menu item without
rebuilding the item and losing its keyboard handling, a spinner in place of the
chevron driven by your own state rather than by the component’s loading prop:
none of those needed a new prop in v11, because each one is a position in the
markup that you can now write into.
The wrapper
All that verbosity sits at the call site, and the call site is code you control, so assemble the parts once in a file of your own:
// components/select.tsx
'use client';
import { ChevronDown } from '@primeicons/react';
import { Select as PRSelect, type SelectRootProps } from '@primereact/ui/select';
export function Select({ placeholder, ...props }: SelectRootProps & { placeholder?: string }) {
return (
<PRSelect.Root {...props}>
<PRSelect.Trigger>
<PRSelect.Value placeholder={placeholder} />
<PRSelect.Indicator>
<ChevronDown />
</PRSelect.Indicator>
</PRSelect.Trigger>
<PRSelect.Portal>
<PRSelect.Positioner>
<PRSelect.Popup>
<PRSelect.List />
</PRSelect.Popup>
</PRSelect.Positioner>
</PRSelect.Portal>
</PRSelect.Root>
);
}
There is no forwardRef in there. These are React 19 function components, where
ref is an ordinary prop, so it travels with the rest spread like any other
prop and the wrapper does not have to do anything about it.
@primereact/ui/select exports Select as a namespace of parts, which is what
makes PRSelect.Root resolve. The same module also exports each part under its
full name (SelectRoot, SelectTrigger) if you prefer to import them
individually for tree-shaking.
The call site is back to one line now, and it is close enough to the v10 one that a find-and-replace does most of the work:
<Select value={city} options={cities} optionLabel="name" placeholder="Select a City" onValueChange={(e) => setCity(e.value)} />
The rest props spread onto Root, so every data and behaviour prop still works
without being listed. options, optionLabel, optionValue, optionDisabled,
optionGroupLabel, disabled, invalid and variant all stayed on Root
untouched. Only the things that moved into parts, placeholder here, need
naming.
Where MultiSelect went
The wrapper pattern matters most for the component that no longer exists in v11.
MultiSelect is not a separate component any more. It is Select with a
multiple prop:
// v10
<MultiSelect value={selected} onChange={(e) => setSelected(e.value)}
options={cities} optionLabel="name" display="chip" maxSelectedLabels={3} />
// v11
<Select.Root multiple value={selected} onValueChange={(e) => setSelected(e.value)}
options={cities} optionLabel="name">
The parts and the prop names are the Select ones. What changed is the trigger
label. display, maxSelectedLabels and selectedItemsLabel are gone, and you
compute the summary text yourself as children of <Select.Value>. The
placeholder still comes from the prop, and children win when there is a
selection:
<Select.Value placeholder="Select toppings">{getLabel()}</Select.Value>
Checkbox-style marks next to each option are <Select.OptionIndicator>, which
replaces checkboxIcon and itemCheckboxIcon. selectAll and showSelectAll
have no built-in counterpart, so a select-all control goes in <Select.Header>
and drives the value directly. If you write a MultiSelect of your own that
hard codes multiple, the option indicator and your label function, then a
component the library deleted goes on existing in your codebase as a one-line
import, and the screens using it do not need to know it changed.
How the Tailwind variant does it
PrimeReact ships its own Tailwind variant built exactly this way.
packages/tailwind/src/select.tsx wraps each primitive part, bakes in the
chevron and check icons, applies its classes, and re-exports the whole set as
named components:
export { Select, SelectArrow, SelectClear, SelectEmpty, SelectFilter, SelectFooter, SelectHeader, SelectList, SelectOption, SelectPopup, SelectPortal, SelectPositioner, SelectTrigger, SelectValue };
Read that file before you write your own wrapper. It is the same technique at production scale, and the shape to copy from it is one wrapper per part rather than one wrapper around the whole thing.
The parts are still importable
The wrapper above bakes in a fixed arrangement, which is fine for the ninety percent of screens that want the default. For the rest you do not have to fight the wrapper, because the underlying parts stay importable on their own and you can drop down to them on the one screen that needs it.
// Ordinary screen
<Select value={city} options={cities} />
// The one screen that needs a footer in the panel
<PRSelect.Root value={city} options={cities}>
...
<PRSelect.Popup>
<PRSelect.List />
<PRSelect.Footer>
<Button onClick={addCity}>Add a city</Button>
</PRSelect.Footer>
</PRSelect.Popup>
...
</PRSelect.Root>
With the wrapper covering the bulk of the screens and the parts covering the exceptions, you can convert the application screen by screen instead of in one pass that has to land all at once.
There is a middle option too. A wrapper that forwards children bakes in the common arrangement and still lets a caller drop extra parts into the popup when they need to.
DataTable is the one that resists wrapping
Select wraps cleanly because its parts are fixed furniture: there is one
trigger, one popup, one list, and the data flows through props on Root.
DataTable does not have that shape, and you should know that before you budget
the migration.
v10 exposed roughly 149 props on the table plus a <Column> with its own set.
v11 spreads the same ground across more than 40 parts, and the body is a
function of the row:
<DataTable.Root data={products}>
<DataTable.TableContainer>
<DataTable.Table>
<DataTable.THead>
<DataTable.THeadRow>
<DataTable.THeadCell>
<DataTable.THeadTitle>Code</DataTable.THeadTitle>
</DataTable.THeadCell>
</DataTable.THeadRow>
</DataTable.THead>
<DataTable.TBody>
{({ item }) => (
<DataTable.Row key={item.id}>
<DataTable.Cell>{item.code}</DataTable.Cell>
</DataTable.Row>
)}
</DataTable.TBody>
</DataTable.Table>
</DataTable.TableContainer>
</DataTable.Root>
<Column> had to go because of how it worked in v10. Its entire source was
export const Column = () => {}, so it rendered nothing at all. DataTable
walked props.children, read column.props off each element and built the
table from that, which means the <Column> you wrote was a marker for the table
to read rather than a component that produced anything. That is why a
<PriceColumn /> of your own broke the table: DataTable inspected its direct
children and stopped there, so it saw a component it did not recognise instead
of the column inside.
In v11 those cells and columns are real components, so you wrap at that level here instead of wrapping the whole table the way you did with Select:
function PriceCell({ value }) {
return <DataTable.Cell className="text-right tabular-nums">{formatPrice(value)}</DataTable.Cell>;
}
Watch the renames while you are in there: value became data, selection
became selectionKeys, first became page, expandedRows became
expandedKeys, onPage became onPageChange and onSort became
onSortChange. Most behaviour props kept their names, including dataKey,
loading, lazy, totalRecords, scrollable, sortField, paginator,
editMode and globalFilter.
Two things have no drop-in replacement. stateKey and stateStorage were
dropped, so persisting table state is application code now. And the imperative
methods are gone: exportCSV() became the <DataTable.Export> part backed by
useDataTableExport, while reset(), filter(), closeEditingCell(),
saveState() and restoreState() are handled through controlled state props or
the feature hooks under @primereact/headless/datatable/features.
The other two changes
Composition generates the most diff, but there are two more changes to plan for before you start.
Class components became function components. There is no imperative instance
API in v11, so toast.current.show(...) and dt.current.exportCSV() do not
exist. What was a method is now state or a headless hook, which changes how you
organize the code around a component and not only how you write the component
itself. Budget for that separately, because in most migrations it is the more
invasive of the two.
Content comes from children. Labels, icons, headers, footers and item templates were five different mechanisms in v10, each with its own prop and its own rules, and they are one mechanism now: you write them as children of the part that owns that position.
// v10
<Button icon="pi pi-check" label="Save" />
// v11
<Button><Check /> Save</Button>
label is gone and iconPos has no counterpart, since the order of the
children is what puts the icon before or after the text. The appearance booleans
collapsed too, so text, outlined and link are now
variant="text" | "outlined" | "link".
Controlled state naming
One more thing that shows up in every file you touch. The naming is the same across every component now:
| Concept | v10 | v11 |
|---|---|---|
| Value | value + onChange |
value + defaultValue + onValueChange |
| Checked | checked + onChange |
checked + defaultChecked + onCheckedChange |
| Open | visible + onHide |
open + defaultOpen + onOpenChange |
| Active | activeIndex + onTabChange |
value + defaultValue + onValueChange |
Every component has a defaultX for the uncontrolled case now, which v10 mostly
did not. The event object kept its shape. DropdownChangeEvent became
SelectValueChangeEvent, but e.value and e.originalEvent read the same as
before, so the bodies of your handlers usually survive the rename untouched.
Where to start
Write the wrappers for your three or four most-used components first. In a typical application that alone converts most of the call sites, and it leaves you a migration you can do a screen at a time rather than a branch that has to land all at once.