PrimeFaces Data Components
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PrimeFaces 16.0.0 (released 2026-09-04), whose migration guide states that Jakarta Faces 4.0 is the baseline, on
Jakarta Faces 4.1 with Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, Java 17 or later (the examples were compiled with
--release 21). Faces 4.0 behaves the same for everything below. The examples were deployed in a scratch Bookshelf WAR on Apache Tomcat
11.0.26 with Weld 6.0.4.Final, once with Mojarra and once with MyFaces, and driven in headless Chrome: the numbers, request
parameters, generated markup and every "what happened" statement come from those runs (checked on 2026-10-08), and the attributes were checked
against the tag library inside the 16.0.0 jar. Where a statement rests only on the PrimeFaces documentation it says so.
The standard h:dataTable of the data tables page renders what it is given: no sorting, no
filtering, no pager. The PrimeFaces data components add all of that, plus selection, in-cell editing, trees, calendars and export, and they do it
with Ajax: every click on a column header, page link or filter field is a partial request that re-renders the table body. This page walks through
them with one running example, the PrimeFaces variant of the Bookshelf catalogue: a table of books that grows from a plain list into a
filterable, editable, selectable, lazy-loaded and exportable one. The setup of PrimeFaces itself (dependencies, context parameters, themes) is on the
getting started page of the PrimeFaces part of this section; the Ajax model (process, update) on the PrimeFaces Ajax page; dialogs, panels and
menus on their page, and file handling, charts and drag and drop on theirs. The table models and the view state they keep are discussed again for a cluster on the deployment page, and the browser tests of tables on the testing page.
How the Data Components Fit into Faces
p:dataTable, p:dataView, p:dataGrid, p:dataList and p:dataScroller are subclasses of the standard jakarta.faces.component.UIData
(through org.primefaces.component.api.PrimeUIData; the class hierarchy was read in the 16.0.0 jar, and PrimeUIData was called UIData before
15.0.0). They iterate a DataModel row by row exactly as the standard table does, so the cursor, the var request attribute, the client ids
with a row index (form:books:2:title) and the per-row state saving of the iteration section all
apply. p:tree, p:treeTable and p:schedule are different: they extend UIComponentBase through PrimeFaces' own base classes and are
driven by their own model objects (TreeNode, ScheduleModel) instead of a DataModel.
What PrimeFaces adds is a set of features that the table component handles itself, each triggered by an Ajax request that carries a marker parameter. The requests below were captured from the running pages; the client ids are those of the pages in this section:
| Action in the browser | Request parameters (besides the form fields and the view state) | Behavior event name |
|---|---|---|
Click a column header |
|
|
Type in a filter field |
|
|
Click a page link |
|
|
Select a row |
|
|
Expand a row |
|
|
Save an edited row |
|
|
Edit a cell |
|
|
Scroll a lazy |
|
|
Expand a tree node |
|
|
The event names come from the components' getEventNames() in the 16.0.0 jar (listed in Ajax Events of the Data Components); a p:ajax child with event="..." and
a listener turns each of them into a server-side callback, in the way the client behaviors page describes
for f:ajax. Two details matter for the lifecycle. First, because the browser sends execute=form:books, the request runs all six phases for the table
component only: Apply Request Values decodes the feature parameters and the selection, and the listener of a p:ajax behavior runs in Invoke Application, or in
Process Validations for filter (the run logged onSort INVOKE_APPLICATION, onFilter PROCESS_VALIDATIONS, onRowSelect INVOKE_APPLICATION). Second, the sorted,
filtered or paged result is computed when the table renders, in Render Response; a filteredValue setter was called in that phase.
The lifecycle page describes the phases.
The Data and the Beans Used on This Page
The examples use a small catalogue of 24 books. A mutable JavaBean is needed because in-cell editing writes through setters, so the immutable
Book record of the data tables page is replaced here by BookRow; RowService is an application-scoped stand-in for the repository
behind the catalogue (the paging service of the standard-table page is the same idea). The
only two things worth noticing in RowService (shown with its seed data abridged) are that it hands out copies (a table sorts the list it is given), and that it only accepts the column names in its FIELDS map as sort keys. The
the persistence page of this section covers a real repository (lazy paging); the JPA version of the lazy table is shown below.
package org.example.bookshelf.model;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Objects;
/** The editable row type of the PrimeFaces catalogue: a JavaBean, because in-cell editing writes through setters. */
public class BookRow implements Serializable {
private long id;
private String title;
private String author;
private String genre;
private int year;
private BigDecimal price;
private boolean available;
public BookRow() {
}
public BookRow(long id, String title, String author, String genre, int year, String price, boolean available) {
this.id = id;
this.title = title;
this.author = author;
this.genre = genre;
this.year = year;
this.price = new BigDecimal(price);
this.available = available;
}
public BookRow copy() {
BookRow c = new BookRow();
c.id = id;
c.title = title;
c.author = author;
c.genre = genre;
c.year = year;
c.price = price;
c.available = available;
return c;
}
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getGenre() { return genre; }
public void setGenre(String genre) { this.genre = genre; }
public int getYear() { return year; }
public void setYear(int year) { this.year = year; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public boolean isAvailable() { return available; }
public void setAvailable(boolean available) { this.available = available; }
@Override
public boolean equals(Object o) {
return o instanceof BookRow other && other.id == id;
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
package org.example.bookshelf.service;
import jakarta.enterprise.context.ApplicationScoped;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Function;
import java.util.stream.Stream;
import org.example.bookshelf.model.BookRow;
/**
* In-memory stand-in for the repository behind the catalogue. The only columns a caller may sort or filter by are
* the keys of FIELDS: the field names arrive from the browser, so an unknown name is refused, never concatenated into a query.
*/
@ApplicationScoped
public class RowService {
private static final Map<String, Function<BookRow, Comparable<?>>> FIELDS = Map.of(
"title", BookRow::getTitle,
"author", BookRow::getAuthor,
"genre", BookRow::getGenre,
"year", BookRow::getYear,
"price", BookRow::getPrice);
private final List<BookRow> rows = new CopyOnWriteArrayList<>(List.of(
new BookRow(1, "Pride and Prejudice", "Jane Austen", "Romance", 1813, "9.90", true),
// ... 23 more books: id, title, author, genre, year, price, available
));
public List<BookRow> findAll() {
return new ArrayList<>(rows.stream().map(BookRow::copy).toList());
}
public BookRow byId(long id) {
return rows.stream().filter(r -> r.getId() == id).findFirst().map(BookRow::copy).orElse(null);
}
public void save(BookRow row) {
for (int i = 0; i < rows.size(); i++) {
if (rows.get(i).getId() == row.getId()) {
rows.set(i, row.copy());
}
}
}
/** Case-insensitive "contains" on the text fields; "available" is matched exactly. */
public int count(Map<String, String> filters) {
return (int) filtered(filters).count();
}
public List<BookRow> find(int first, int pageSize, String sortField, boolean ascending, Map<String, String> filters) {
Function<BookRow, Comparable<?>> key = FIELDS.get(sortField == null ? "title" : sortField);
if (key == null) {
throw new IllegalArgumentException("Cannot sort by " + sortField);
}
@SuppressWarnings({"unchecked", "rawtypes"})
Comparator<BookRow> order = (a, b) -> ((Comparable) key.apply(a)).compareTo(key.apply(b));
if (!ascending) {
order = order.reversed();
}
return filtered(filters).sorted(order).skip(first).limit(pageSize).map(BookRow::copy).toList();
}
private Stream<BookRow> filtered(Map<String, String> filters) {
Stream<BookRow> stream = rows.stream();
for (Map.Entry<String, String> f : filters.entrySet()) {
String wanted = f.getValue().toLowerCase(Locale.ROOT);
stream = switch (f.getKey()) {
case "title" -> stream.filter(r -> r.getTitle().toLowerCase(Locale.ROOT).contains(wanted));
case "author" -> stream.filter(r -> r.getAuthor().toLowerCase(Locale.ROOT).contains(wanted));
case "genre" -> stream.filter(r -> r.getGenre().toLowerCase(Locale.ROOT).contains(wanted));
case "year" -> stream.filter(r -> String.valueOf(r.getYear()).startsWith(wanted));
case "available" -> stream.filter(r -> String.valueOf(r.isAvailable()).equals(wanted));
default -> throw new IllegalArgumentException("Cannot filter by " + f.getKey());
};
}
return stream;
}
}
The view-scoped TableBean behind the small tables keeps the list loaded once, the list of filtered rows, the selection, and one method per event:
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.event.CellEditEvent;
import org.primefaces.event.RowEditEvent;
import org.primefaces.event.SelectEvent;
import org.primefaces.event.ToggleEvent;
import org.primefaces.event.UnselectEvent;
import org.primefaces.event.data.FilterEvent;
import org.primefaces.event.data.PageEvent;
import org.primefaces.event.data.SortEvent;
/** One view-scoped bean behind the small tables of this page: the rows, the filtered copy and the selection. */
@Named("table")
@ViewScoped
public class TableBean implements Serializable {
@Inject
private RowService service;
private List<BookRow> rows;
private List<BookRow> filteredRows;
private List<BookRow> selectedRows = new ArrayList<>();
private BookRow selectedRow;
private String lastEvent = "none";
@PostConstruct
void init() {
rows = service.findAll();
}
public List<BookRow> getRows() { return rows; }
public List<BookRow> getFilteredRows() { return filteredRows; }
public void setFilteredRows(List<BookRow> filteredRows) { this.filteredRows = filteredRows; }
public List<BookRow> getSelectedRows() { return selectedRows; }
public void setSelectedRows(List<BookRow> selectedRows) { this.selectedRows = selectedRows; }
public BookRow getSelectedRow() { return selectedRow; }
public void setSelectedRow(BookRow selectedRow) { this.selectedRow = selectedRow; }
public String getLastEvent() { return lastEvent; }
public List<String> getGenres() {
return rows.stream().map(BookRow::getGenre).distinct().sorted().toList();
}
public String getSelectedTitles() {
return selectedRows.stream().map(BookRow::getTitle).toList().toString();
}
public void borrowSelected() {
lastEvent = "borrowed " + selectedRows.size();
}
public void onRowSelect(SelectEvent<BookRow> event) {
lastEvent = "select " + event.getObject().getTitle();
}
public void onRowUnselect(UnselectEvent<BookRow> event) {
lastEvent = "unselect " + event.getObject().getTitle();
}
public void onSort(SortEvent event) {
lastEvent = "sort " + event.getSortBy().keySet();
}
public void onFilter(FilterEvent event) {
lastEvent = "filter " + event.getFilterBy().keySet();
}
public void onPage(PageEvent event) {
lastEvent = "page " + event.getPage() + " rows " + event.getRowsPerPage();
}
public void onRowToggle(ToggleEvent event) {
lastEvent = "toggle " + event.getVisibility() + " " + ((BookRow) event.getData()).getTitle();
}
public void onRowEdit(RowEditEvent<BookRow> event) {
service.save(event.getObject());
lastEvent = "edited " + event.getObject().getTitle();
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(FacesMessage.SEVERITY_INFO, "Saved", event.getObject().getTitle()));
}
public void onRowEditCancel(RowEditEvent<BookRow> event) {
lastEvent = "cancelled " + event.getObject().getTitle();
}
public void onCellEdit(CellEditEvent<Object> event) {
lastEvent = "cell " + event.getOldValue() + " -> " + event.getNewValue();
BookRow row = (BookRow) event.getRowData();
service.save(row);
}
}
All pages share this shell (shown once; the later listings give only what is inside h:body):
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:p="primefaces">
<h:head>
<title>Catalogue</title>
</h:head>
<h:body>
...
</h:body>
</html>
The namespace of the PrimeFaces tags is the URN primefaces (the library registers primefaces.urn.taglib.xml as well as the classic
http://primefaces.org/ui namespace; both were found in the 16.0.0 jar). h:head is required: PrimeFaces writes its stylesheets and scripts into it.
p:dataTable: Columns, Facets and Paging
The smallest useful table binds a List to value, names the row variable and declares the columns. A column can show a bean property directly
with field, or contain any markup:
<h:form id="form">
<p:dataTable id="books" value="#{table.rows}" var="book" widgetVar="booksTable"
paginator="true" rows="5" rowsPerPageTemplate="5,10,25"
paginatorTemplate="{CurrentPageReport} {FirstPageLink} {PreviousPageLink} {PageLinks} {NextPageLink} {LastPageLink} {RowsPerPageDropdown}"
currentPageReportTemplate="{startRecord}-{endRecord} of {totalRecords}"
emptyMessage="No books found" stripedRows="true">
<f:facet name="header">The Bookshelf catalogue</f:facet>
<p:column headerText="Title" field="title"/>
<p:column headerText="Author" field="author"/>
<p:column headerText="Year" field="year" width="80"/>
<p:column headerText="Price" style="text-align: right">
<h:outputText value="#{book.price}">
<f:convertNumber type="currency" currencySymbol="EUR "/>
</h:outputText>
</p:column>
<f:facet name="footer">#{table.rows.size()} books</f:facet>
</p:dataTable>
<h:outputText id="last" value="#{table.lastEvent}"/>
</h:form>
What the run showed:
-
Five of 24 rows were rendered, and the page report read "1-5 of 24"; clicking "next" posted
form:books_pagination=true&form:books_first=5&form:books_rows=5and the second page held the sixth to tenth rows with the report "6-10 of 24". Choosing "10" in the rows-per-page dropdown re-rendered ten rows. All of that is one Ajax request each; no full page load. -
A column with
field="title"and no body shows#{book.title}as text (escaped); the body of the column is what you use to format, as the price column does withf:convertNumber. -
fielddoes more than name a property: it makes the column sortable and filterable. The header of the first column rendered a text filter input (<input ... type="search" class="ui-column-filter ...">) although the page never asked for filtering. Switch it off per column withsortable="false"orfilterable="false", or useheaderTextwith a body (as the price column does) if the column should be plain. -
paginatorTemplateaccepts the keys{FirstPageLink},{PreviousPageLink},{PageLinks},{NextPageLink},{LastPageLink},{CurrentPageReport},{RowsPerPageDropdown},{JumpToPageDropdown}and{JumpToPageInput};currentPageReportTemplateaccepts{currentPage},{totalPages},{totalRecords},{startRecord}and{endRecord};paginatorPositionistop,bottomorboth(the 16.0.0 taglib says the default isbottom, the documentation text saysboth; the rendered table had only the bottom paginator). A facet whose name is a template key you invent ({MyContent}) is rendered where the key appears (documentation, not run). The key names are those of the documentation; the taglib lists the attributes. -
The attributes that change only the look:
stripedRows,showGridlines,size(small,regular,large),rowHover,rowStyleClass(an expression evaluated for each row),tableStyle,emptyMessageand theemptyMessagefacet, which accepts markup. -
clientCache="true"makes the table fetch the next page in the background so that "next" is instant (documentation);reflow="true"andresponsivePriorityon columns are the two responsive modes;stickyHeader="true"keeps the header visible while the page scrolls. These were read in the documentation and the taglib and not run.
Header and footer. f:facet name="header" and name="footer" on the table, and the same facets or the headerText and footerText shortcuts on a column.
escapeText (default true in the taglib) escapes headerText and footerText; leave it on for any text that comes from users.
The value is the model. A List, an array or any Collection works, as does any DataModel. The documentation advises against keeping the list in a longer-lived
scope such as @ViewScoped, to avoid large sessions and serialisation: bind a request-scoped bean as in its getting started example, or a
LazyDataModel that loads on the fly (the lazy section below). The view-scoped TableBean of this page is the other trade-off, kept for the
filtering and selection examples because 24 rows are cheap; it is the same trade-off the data tables page makes for its editable table.
Sorting
Defining field (or sortBy) on a column makes it sortable. Clicking the header cycles ascending, descending and, only with allowUnsorting="true", unsorted.
sortMode is multiple by default in the 16.0.0 taglib: a click sorts by that column alone, and a click with the Ctrl or Meta key held adds it to the sort group.
A column’s sortOrder (asc or desc) with a sortPriority sets the initial sort; nullSortOrder, caseSensitiveSort and sortFunction refine it.
<h:form id="form">
<p:dataTable id="books" value="#{table.rows}" var="book" widgetVar="booksTable"
filteredValue="#{table.filteredRows}" paginator="true" rows="5"
allowUnsorting="true" sortMode="multiple" emptyMessage="No books match">
<p:ajax event="sort" listener="#{table.onSort}" update=":form:last"/>
<p:ajax event="filter" listener="#{table.onFilter}" update=":form:last"/>
<p:ajax event="page" listener="#{table.onPage}" update=":form:last"/>
<f:facet name="header">
<p:inputText id="globalFilter" onkeyup="PrimeFaces.debounce(() => PF('booksTable').filter())"
placeholder="Search all columns"/>
</f:facet>
<p:column headerText="Title" field="title" filterMatchMode="contains"/>
<p:column headerText="Author" field="author" filterMatchMode="contains"/>
<p:column headerText="Genre" field="genre" filterMatchMode="exact">
<f:facet name="filter">
<p:selectOneMenu onchange="PF('booksTable').filter()" styleClass="ui-custom-filter">
<f:selectItem itemLabel="All" itemValue="#{null}" noSelectionOption="true"/>
<f:selectItems value="#{table.genres}"/>
</p:selectOneMenu>
</f:facet>
<h:outputText value="#{book.genre}"/>
</p:column>
<p:column headerText="Year" field="year" filterMatchMode="gte" sortOrder="asc" sortPriority="1">
<f:facet name="filter">
<p:spinner onchange="PF('booksTable').filter()" min="1800" max="1910">
<f:converter converterId="jakarta.faces.Integer"/>
</p:spinner>
</f:facet>
<h:outputText value="#{book.year}"/>
</p:column>
<p:column headerText="Cost" field="price" filterable="false"/>
</p:dataTable>
<h:outputText id="last" value="#{table.lastEvent}"/>
</h:form>
What the run showed on this page (Mojarra and MyFaces gave the same results):
-
The table first rendered sorted by year (the column with
sortOrder="asc" sortPriority="1"): 1811, 1813, 1815, 1817, 1818. -
A click on "Title" gave "Adventures of Huckleberry Finn", "Alice in Wonderland", "A Tale of Two Cities" as the first three rows (the order ignores the space, as a locale-aware comparison would; the taglib says
dataLocaleis the locale used in features such as sorting, andcaseSensitiveSortisfalseby default), a second click reversed the order, and a third click (unsorted) left the rows in the order of the second click. That is the next point. -
PrimeFaces sorts the list you bound, in place. The request that sorts
table.rowsreorders that veryArrayList; "unsorted" can only stop sorting, it cannot restore the original order of a plain list. Two consequences were observed: aListthat is immutable (List.of(...),Stream.toList()) fails the first render withUnsupportedOperationExceptionraised inSortFeature.sort, and a getter that builds a new list on every call (return service.findAll();) makes the sort seem to do nothing, because the table sorts one copy and renders another. Bind a list that the bean created once, and let two tables have separate lists: a second table bound to the same list showed rows already sorted by the first one. -
A Ctrl+click on "Year" after a click on "Author" gave rows sorted by author and then year; the event map (
SortEvent.getSortBy()) had oneSortMetafor each sorted column, keyed by the client id of the column, not by the field name (form:books:j_idt5on Mojarra,form:books:j_id_9on MyFaces). Read the field fromSortMeta.getField(). -
A bare
sortBy="#{book.genre}"on the table did not sort the initial render in 16.0.0 (checked with and withoutheaderRow); the 10.0.0 migration guide saysDataTable#sortByexpects a singleSortMetaor a collection of them. Use the column attributes or aList<SortMeta>built withSortMeta.builder().field("genre").order(SortOrder.ASCENDING).priority(1).build(). ASortMetanames its column byfield, so a column with that field must exist: aSortMetaforyearon a table without such a column failed withFacesException: No column with field 'year' or columnKey 'null' has been found. -
sortFunctiontakes a method with three parameters since 14.0.0:public int byTitle(Object a, Object b, SortMeta meta)(14.0.0 migration guide).DataTable#sortField,#sortOrderand#sortFunctionwere removed in 10.0.0 in favour of the column attributes; asortOrderwritten on the table is silently ignored (checked), since the attribute no longer exists and Faces does not complain about unknown attributes of a component tag.
Filtering
A column filters when it has field or filterBy. The filter box in the header is a text input; a filter facet replaces it with any component, and an input named
globalFilter inside the table filters every column at once. All three are on the sortfilter.xhtml page above, and what each did was:
| Control | Attributes | Run |
|---|---|---|
Global filter |
|
Typing |
Text filter |
|
|
|
|
Choosing "Gothic" kept the five Gothic books |
|
|
Year at least 1890 on top of "Gothic" left "The Picture of Dorian Gray" and "Dracula". Without the converter the filter value would be a string compared to an integer |
The match modes are startsWith, notStartsWith, endsWith, notEndsWith, contains, notContains, exact, notExact, lt, lte, gt, gte,
equals, notEquals, in, notIn, between and notBetween (documentation; the org.primefaces.model.MatchMode enum of the jar has the same constants plus
GLOBAL). The mode range was removed in 12.0.0 in favour of between. The other controls are filterEvent (keyup by default, enter to wait for the
return key), filterDelay (300 ms), filterNormalize (ignore accents, false by default, new in 15.0.0), filterMaxLength, filterPlaceholder,
filterFunction (a method taking the cell value, the filter value and the Locale and returning a boolean), globalFilterFunction and globalFilterOnly. A default filter is a column’s
filterValue or a List<FilterMeta> built with FilterMeta.builder().field("year").filterValue(...).matchMode(MatchMode.CONTAINS).build() bound to the table’s filterBy.
Where the filtered list lives. On a table bound to a list, bind filteredValue to a property; the documentation says that a scope longer than the request is needed to
keep it ("@ViewScoped", not @SessionScoped; use multiViewState="true" to keep a table’s state across views). The setter of TableBean.setFilteredRows was called in
Render Response. A lazy table needs none of this: the model receives the filters.
Filter events carry the Map<String, FilterMeta>; since 10.0.0 they no longer carry the filtered list, so read it from your bean.
Selection and the rowKey
Selection needs two attributes: selection, bound to a property, and rowKey, an expression that identifies a row. Whether the selection is single or multiple is deduced from
the type of the property (a List, Set or array selects several rows, anything else one; the documentation says so since 13.0.0 and that an explicit selectionMode is no longer
needed). Rows are selected by clicking a row (selectionRowMode: new replaces the selection, add keeps it, none turns row clicks off), or through a column with selectionBox="true", which
shows checkboxes for a multiple selection and radio buttons for a single one.
<h:form id="form">
<p:dataTable id="books" value="#{table.rows}" var="book" rowKey="#{book.id}"
selection="#{table.selectedRows}" paginator="true" rows="5"
selectionPageOnly="false">
<p:ajax event="rowSelectCheckbox" listener="#{table.onRowSelect}" update=":form:last :form:sel"/>
<p:ajax event="rowUnselectCheckbox" listener="#{table.onRowUnselect}" update=":form:last :form:sel"/>
<p:ajax event="toggleSelect" update=":form:sel"/>
<p:column selectionBox="true" width="40"/>
<p:column headerText="Title" field="title"/>
<p:column headerText="Author" field="author"/>
</p:dataTable>
<p:commandButton id="borrow" value="Borrow selected" action="#{table.borrowSelected}" update="sel"/>
<h:outputText id="last" value="#{table.lastEvent}"/>
<h:outputText id="sel" value="#{table.selectedTitles}"/>
</h:form>
<h:form id="single">
<p:dataTable id="one" value="#{table.rows}" var="book" rowKey="#{book.id}"
selection="#{table.selectedRow}" selectionRowMode="add" paginator="true" rows="3">
<p:column selectionBox="true" width="40"/>
<p:column headerText="Title" field="title"/>
</p:dataTable>
<h:outputText id="one-sel" value="#{table.selectedRow.title}"/>
</h:form>
What the run showed:
-
The selection travels as a hidden field,
form:books_selection=1,3,7: the row keys, joined by commas. After clicking on page 1 and page 2, the "Borrow selected" button (ap:commandButton, which processes@allby default) received three books, so the selection survives paging. This is why arowKeymust not contain a comma (PrimeFaces issue 8932, quoted in the documentation). -
"Select all" in the header with
selectionPageOnly="false"posted@all, and the bean received all 24 books; the defaultselectionPageOnly="true"selects only the current page. -
The event names depend on how the row was selected. A
p:ajax event="rowSelect"is fired by a row click, but the checkbox column firesrowSelectCheckboxandrowUnselectCheckbox(a radio column firesrowSelectRadioby its name; not run); the header checkbox firestoggleSelect. Withevent="rowSelect"and a checkbox column the listener was never called and the page showed "none" until the events were changed. The listener receives aSelectEvent<BookRow>(getObject()) or anUnselectEvent<BookRow>; the setter of the selection ran in Apply Request Values and the listener in Invoke Application. -
A table without
rowKeyfails. A non-lazy table withselectionand norowKeyreturned HTTP 500 withUnsupportedOperationException: DataTable#rowKey must be defined for component form:books. The 11.0.0 migration guide introduced the requirement (and forp:rowExpansion). -
Forged keys are dropped. The hidden field was edited in the browser to
1,999,abc,,and the bean still received only the real book: the table resolves each key against the rows it has. A lazy table asks the model, throughgetRowData(String), and theRowLazyModelbelow returnsnullfor a key that is not a number or not a book; the unknown keys were dropped there too. A model that throws on a bad key would turn a forged field into a server error. Whatever the action does with the selected rows, check on the server that the current user may act on each of them: the selection says which rows the browser claims, not which rows it may use.
Other attributes: selectionDisabled, selectRow="false" on a column to exclude it from row clicks (useful for a column with buttons), onRowClick, rowSelector.
The server API DataTable.selectRow(rowKey) and unselectRow(rowKey) (documentation) and the client API selectRow(r, silent), selectAllRows(), unselectAllRows() and
getSelectedRowsCount() of the widget are for programmatic changes.
In-Cell Editing
A table is editable with editable="true" and one of two modes. In row mode (the default) a pencil icon turns the row into inputs and a tick or a cross saves or cancels it. In
cell mode a click (or the event in editInitEvent) turns one cell into an input. Each editable column contains a p:cellEditor with an output and an input facet; row mode adds a column holding p:rowEditor.
<h:form id="form">
<p:growl id="msgs" showDetail="true"/>
<p:dataTable id="books" value="#{table.rows}" var="book" editable="true" editMode="row"
rowKey="#{book.id}" paginator="true" rows="5">
<p:ajax event="rowEdit" listener="#{table.onRowEdit}" update=":form:msgs :form:last"/>
<p:ajax event="rowEditCancel" listener="#{table.onRowEditCancel}" update=":form:last"/>
<p:column headerText="Title">
<p:cellEditor>
<f:facet name="output"><h:outputText value="#{book.title}"/></f:facet>
<f:facet name="input"><p:inputText id="title" value="#{book.title}" required="true" style="width: 100%"/></f:facet>
</p:cellEditor>
</p:column>
<p:column headerText="Year">
<p:cellEditor>
<f:facet name="output"><h:outputText value="#{book.year}"/></f:facet>
<f:facet name="input"><p:spinner id="year" value="#{book.year}" min="1400" max="2100"/></f:facet>
</p:cellEditor>
</p:column>
<p:column headerText="Available">
<p:cellEditor>
<f:facet name="output"><h:outputText value="#{book.available ? 'yes' : 'no'}"/></f:facet>
<f:facet name="input"><p:selectBooleanCheckbox id="available" value="#{book.available}"/></f:facet>
</p:cellEditor>
</p:column>
<p:column style="width: 6rem"><p:rowEditor/></p:column>
</p:dataTable>
<h:outputText id="last" value="#{table.lastEvent}"/>
</h:form>
<h:form id="cells">
<p:growl id="msgs2"/>
<p:dataTable id="books" value="#{table.rows}" var="book" editable="true" editMode="cell"
editInitEvent="dblclick" rowKey="#{book.id}" paginator="true" rows="5">
<p:ajax event="cellEdit" listener="#{table.onCellEdit}" update=":cells:msgs2 :cells:last"/>
<p:column headerText="Title">
<p:cellEditor>
<f:facet name="output"><h:outputText value="#{book.title}"/></f:facet>
<f:facet name="input"><p:inputText id="title" value="#{book.title}"/></f:facet>
</p:cellEditor>
</p:column>
<p:column headerText="Author">
<p:cellEditor>
<f:facet name="output"><h:outputText value="#{book.author}"/></f:facet>
<f:facet name="input"><p:inputText id="author" value="#{book.author}"/></f:facet>
</p:cellEditor>
</p:column>
</p:dataTable>
<h:outputText id="last" value="#{table.lastEvent}"/>
</h:form>
The Java side is in TableBean above: onRowEdit(RowEditEvent<BookRow>), onRowEditCancel and onCellEdit(CellEditEvent<Object>). What the run showed:
-
Saving a row posted
form:books_rowEditIndex=0&form:books_rowEditAction=saveand only the inputs of that row (form:books:0:title=...); the listener ran,RowService.savestored the change, and the growl said "Saved". A title containing<b>x</b>came back as the literal text, becauseh:outputTextescapes. -
Validation works in row mode. With the required title emptied the request answered with the message "form:books:0:title: Validation Error: Value is required." (the client id is the label because the input has none), the row stayed in edit mode with the style class
ui-state-error, and the listener did not run. Give inputs alabelso the message names the field. -
A cell edit posted
form:books_cellInfo=0,1and the input, and the listener sawJane Austen -> J. AustenthroughgetOldValue()andgetNewValue();getRowData()is the row object, already changed. -
The documentation warns that
requiredand Bean Validation do not apply in cell mode (it links a PrimeFaces discussion). That was not run; validate in the listener and add aFacesMessage. -
rowEditModeandcellEditMode(eagerorlazy) control whether the inputs are rendered up front or fetched when editing starts.
An editing request is a state-changing request that names a row by index. The browser decides which index to post, so the listener must check that the current user may change that row (the object comes from the
table, but the table is still showing what an earlier request left in the view state). The same applies to the p:commandButton actions you place in a column. The security page of this section covers authorisation for views and services.
Row Expansion and Column Toggling
p:rowToggler in a column puts an expand icon in each row, and p:rowExpansion holds the content that appears under the row. The content is fetched by Ajax when a row is first opened; rowExpandMode is multiple by default
and single closes the previous row. rowKey is required (11.0.0). p:columnToggler offers a pop-up list of checkboxes that shows and hides columns: datasource is the table, trigger the button that opens it. A column with
toggleable="false" is not listed, and visible="false" starts it hidden. The same page also makes the columns resizable and reorderable.
<h:form id="form">
<p:dataTable id="books" value="#{table.rows}" var="book" rowKey="#{book.id}" widgetVar="booksTable"
rowExpandMode="single" paginator="true" rows="5" resizableColumns="true" draggableColumns="true">
<p:ajax event="rowToggle" listener="#{table.onRowToggle}" update=":form:last"/>
<f:facet name="header">
<p:commandButton id="toggler" type="button" value="Columns" icon="pi pi-align-justify"/>
<p:columnToggler datasource="books" trigger="toggler">
<p:ajax event="toggle"/>
</p:columnToggler>
</f:facet>
<p:column style="width: 2.5rem" toggleable="false"><p:rowToggler/></p:column>
<p:column headerText="Title" field="title" toggleable="false"/>
<p:column headerText="Author" field="author"/>
<p:column headerText="Genre" field="genre" visible="false"/>
<p:column headerText="Year" field="year"/>
<p:rowExpansion>
<h:panelGrid columns="2" id="detail">
<h:outputText value="Price:"/>
<h:outputText value="#{book.price}"/>
<h:outputText value="Available:"/>
<h:outputText value="#{book.available}"/>
</h:panelGrid>
</p:rowExpansion>
</p:dataTable>
<h:outputText id="last" value="#{table.lastEvent}"/>
</h:form>
Source: PrimeFaces 16 documentation: DataTable (Expandable Rows, Column Toggler) and PrimeFaces 16 documentation: ColumnToggler
What the run showed:
-
The first expansion posted
form:books_rowExpansion=true&form:books_expandedRowIndex=0&form:books_expandedRowKey=1, the listener received aToggleEvent(getVisibility():VISIBLE,getData(): the row) and the detail panel appeared. -
With
rowExpandMode="single"opening a second row hid the first. The first row’s content stayed in the markup, hidden, which is worth knowing if the content is expensive. -
The toggler listed "Author", "Genre" and "Year" plus a select-all box; the "Title" column (
toggleable="false") and the toggler column were not offered. "Genre" started hidden (visible="false"), its header carriedui-helper-hidden. Unchecking "Author" hid its header cell and column. -
The
toggleevent ofp:columnTogglerdelivers aColumnToggleEvent(aToggleEventthat also hasgetColumn()), changed from a plainToggleEventin 14.0.0.
Server-side, DataTable.expandRow(rowKey) and collapseRow(rowKey) open rows from an action, and the client collapseAllRows() closes all.
Dynamic Columns, Grouping and Scrolling
p:columns creates columns from a collection; p:columnGroup builds multi-row headers and footers from p:row and p:column rowspan colspan; p:headerRow and p:summaryRow group the body, and groupRow="true" on a column merges equal cells with
rowspan. A table can also scroll with a fixed header and frozen rows or columns:
<h:form id="form">
<p:dataTable id="byGenre" value="#{group.rows}" var="book">
<p:headerRow field="genre" expandable="true">
<p:column colspan="3"><h:outputText value="#{book.genre}"/></p:column>
</p:headerRow>
<p:column headerText="Title"><h:outputText value="#{book.title}"/></p:column>
<p:column headerText="Year"><h:outputText value="#{book.year}"/></p:column>
<p:column headerText="Price"><h:outputText value="#{book.price}"/></p:column>
<p:summaryRow>
<p:column colspan="2" style="text-align: right"><h:outputText value="Total:"/></p:column>
<p:column><h:outputText value="#{group.totalFor(book.genre)}"/></p:column>
</p:summaryRow>
</p:dataTable>
<p:dataTable id="spans" value="#{group.rows}" var="book" paginator="true" rows="8">
<p:column headerText="Genre" field="genre" groupRow="true" sortOrder="asc"/>
<p:column headerText="Title" field="title"/>
<p:column headerText="Year" field="year"/>
</p:dataTable>
<p:dataTable id="header2" value="#{group.rows}" var="book" paginator="true" rows="4">
<p:columnGroup type="header">
<p:row>
<p:column rowspan="2" headerText="Title"/>
<p:column colspan="2" headerText="Details"/>
</p:row>
<p:row>
<p:column headerText="Author"/>
<p:column headerText="Year"/>
</p:row>
</p:columnGroup>
<p:column><h:outputText value="#{book.title}"/></p:column>
<p:column><h:outputText value="#{book.author}"/></p:column>
<p:column><h:outputText value="#{book.year}"/></p:column>
</p:dataTable>
<p:dataTable id="dyn" value="#{group.rows}" var="book" paginator="true" rows="3">
<p:columns value="#{group.columns}" var="col" columnIndexVar="i"
headerText="#{col.header}" field="#{col.property}" sortBy="#{book[col.property]}">
<h:outputText value="#{book[col.property]}"/>
</p:columns>
</p:dataTable>
<p:dataTable id="scroll" value="#{group.rows}" var="book" scrollable="true" scrollHeight="150"
frozenRows="1" scrollWidth="300" frozenColumns="1">
<p:column headerText="Title" width="200"><h:outputText value="#{book.title}"/></p:column>
<p:column headerText="Author" width="200"><h:outputText value="#{book.author}"/></p:column>
<p:column headerText="Genre" width="200"><h:outputText value="#{book.genre}"/></p:column>
</p:dataTable>
</h:form>
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.model.SortMeta;
import org.primefaces.model.SortOrder;
@Named("group")
@RequestScoped
public class GroupBean {
/** A dynamic column: header text and the bean property it shows. */
public record Col(String header, String property) implements Serializable {
}
@Inject
private RowService service;
private final List<Col> columns = List.of(new Col("Title", "title"), new Col("Author", "author"),
new Col("Genre", "genre"), new Col("Year", "year"));
public List<SortMeta> getDefaultSortBy() {
return List.of(SortMeta.builder().field("genre").order(SortOrder.ASCENDING).priority(1).build());
}
public BigDecimal totalFor(String genre) {
return service.findAll().stream().filter(r -> r.getGenre().equals(genre))
.map(BookRow::getPrice).reduce(BigDecimal.ZERO, BigDecimal::add);
}
private List<BookRow> rows;
@PostConstruct
void init() {
rows = service.findAll();
}
public List<BookRow> getRows() { return rows; }
public List<Col> getColumns() { return columns; }
public BigDecimal getTotalPrice() {
return service.findAll().stream().map(BookRow::getPrice).reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
What the run showed:
-
p:headerRow field="genre"sorted the table by that field without any other sort setting: the groups came out alphabetically (Adventure, Fantasy, Gothic, Mystery, Novel, Romance, Science fiction) and each group had its header row. A group header is built fromp:columnchildren (withcolspan) andexpandable="true"makes the group collapsible (expandedsets its initial state). -
p:summaryRowrendered after each group;bookinside it is a row of the group, sogroup.totalFor(book.genre)gave 36.10 for Adventure (12.00 + 7.60 + 8.10 + 8.40) and 6.70 for Fantasy. Itslistenerattribute is an alternative for computing totals before the row renders (documentation). -
groupRow="true"producedrowspancells (Adventure[rowspan=4]). The rowspan counts only the rows on the page: withrows="8"the Gothic group was cut at the page boundary (rowspan=3). The column was sorted so that equal values are adjacent; without a sort a value that recurs later in the list gets a second merged cell. -
p:columnGroup type="header"rendered two header rows,Titlespanning two rows andDetailstwo columns. Thetypevalues areheader,footer, and, for frozen tables,frozenHeader,frozenFooter,scrollableHeaderandscrollableFooter(documentation). -
p:columnswithvar="col"made four columns from theColrecords, withfield="#{col.property}",sortBy="#{book[col.property]}"and the body#{book[col.property]}; header text came from#{col.header}.columnIndexVarnames the column index. -
A scrollable table got the style class
ui-datatable-scrollable ui-datatable-frozencolumn.scrollable,scrollHeight,scrollWidth,frozenRows,frozenColumnsandfrozenColumnsAlignmentare the attributes. Simple scrolling renders every row;liveScroll="true"andvirtualScroll="true"load rows as the user scrolls and need a lazy model for large data (see below). -
Row grouping does not work with lazy loading and live scroll, according to the documentation, because grouping needs all rows.
-
Tables bound to the same list (here
group.rows) shared one sorted order, as the sorting section warned.
Lazy Loading with LazyDataModel
A table bound to a list renders one page but still needs the whole list in memory on every request. A table bound to an org.primefaces.model.LazyDataModel asks for
one page at a time. The model has two abstract methods (since 11.0.0, when count became mandatory and setRowCount in load stopped being necessary):
| Method | Called with |
|---|---|
|
the active filters; return the number of rows that match, for the paginator |
|
the offset, the page size, the active sorters and filters; return the rows of that page |
getRowKey(T) and getRowData(String rowKey) (or a Converter passed to the constructor) are needed when selection is on. loadOne(...) (renamed from getRowData(rowIndex, sortBy, filterBy) in 14.0.0)
loads a single row. The table detects a lazy model by the type of its value: since 10.0.0 lazy="true" is no longer needed, and the taglib says lazy defaults to true when the value is a LazyDataModel. Use the column attribute field to name what is
sorted and filtered: in load the keys of the sortBy and filterBy maps were the field names (the log printed sort=[year]), whereas the SortEvent of the non-lazy table above is keyed by column client ids; SortMeta.getField() and FilterMeta.getField() return the field in both cases, so use them.
package org.example.bookshelf.web;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.model.FilterMeta;
import org.primefaces.model.LazyDataModel;
import org.primefaces.model.SortMeta;
/** Translates what the table asks for into the service's page-sized query. */
public class RowLazyModel extends LazyDataModel<BookRow> {
private final transient RowService service;
public RowLazyModel(RowService service) {
this.service = service;
}
@Override
public int count(Map<String, FilterMeta> filterBy) {
return service.count(toFilters(filterBy));
}
@Override
public List<BookRow> load(int first, int pageSize, Map<String, SortMeta> sortBy, Map<String, FilterMeta> filterBy) {
String field = "title";
boolean ascending = true;
if (!sortBy.isEmpty()) {
SortMeta sort = sortBy.values().iterator().next();
field = sort.getField();
ascending = sort.getOrder().isAscending();
}
return service.find(first, pageSize, field, ascending, toFilters(filterBy));
}
@Override
public String getRowKey(BookRow row) {
return String.valueOf(row.getId());
}
@Override
public BookRow getRowData(String rowKey) {
try {
return service.byId(Long.parseLong(rowKey));
} catch (NumberFormatException e) {
return null;
}
}
private static Map<String, String> toFilters(Map<String, FilterMeta> filterBy) {
Map<String, String> filters = new LinkedHashMap<>();
for (FilterMeta meta : filterBy.values()) {
if (meta.getFilterValue() != null && !String.valueOf(meta.getFilterValue()).isBlank()) {
filters.put(meta.getField(), String.valueOf(meta.getFilterValue()).trim());
}
}
return filters;
}
}
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.ArrayList;
import java.util.List;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.model.LazyDataModel;
/** Request scoped on purpose: the model is cheap to build and is not kept in the session. */
@Named("lazy")
@RequestScoped
public class LazyBean {
@Inject
private RowService service;
private LazyDataModel<BookRow> model;
private List<BookRow> selected = new ArrayList<>();
@PostConstruct
void init() {
model = new RowLazyModel(service);
}
public LazyDataModel<BookRow> getModel() { return model; }
public List<BookRow> getSelected() { return selected; }
public void setSelected(List<BookRow> selected) { this.selected = selected; }
public String getSelectedTitles() {
return selected.stream().map(r -> r == null ? "null" : r.getTitle()).toList().toString();
}
}
<h:form id="form">
<p:dataTable id="books" value="#{lazy.model}" var="book" lazy="true" rowKey="#{book.id}"
selection="#{lazy.selected}" paginator="true" rows="5" rowsPerPageTemplate="5,10"
sortMode="single" emptyMessage="No books match">
<p:column selectionBox="true" width="40"/>
<p:column headerText="Title" field="title" filterMatchMode="contains"/>
<p:column headerText="Author" field="author" filterMatchMode="contains"/>
<p:column headerText="Genre" field="genre" filterMatchMode="exact"/>
<p:column headerText="Year" field="year"/>
</p:dataTable>
<p:commandButton id="show" value="Show selection" update="sel" process="@form"/>
<h:outputText id="sel" value="#{lazy.selectedTitles}"/>
</h:form>
The model is built in a request-scoped bean on purpose. It holds no rows, so there is nothing to keep, and the documentation advises against keeping a data model in a longer scope such as @ViewScoped (large sessions, serialisation). The RowService field is transient because LazyDataModel is Serializable; the CatalogueLazyModel of the performance page does the same.
What the run showed, with the 24 books and five rows per page:
-
Sorting by "Year" gave 1811 and 1813 first, then (second click) 1903 and 1902; the second page of the descending list started with "The Time Machine"; typing
wellsin the author filter left the two H. G. Wells books. All of it ran throughRowLazyModel.load. -
When the model is called. Logging the phase inside
countandloadshowed: the first GET callscountandloadonce, in Render Response; a sort click or a page click calls them once, in Render Response, with the new state; a filter request calls them twice, first in Apply Request Values with the previous state (the table needs the current rows to decode a selection), then in Render Response with the new filters; and a button withprocess="@form"that does not re-render the table still calls them once in Apply Request Values. Each call is a query in a real repository, so keepcountandloadcheap and do not put a slow side effect into them. -
The field names are not taken from the request. The browser posts the client id of the column (
form:books_sortKey=form:books:j_idt9) and the table maps it to the column’sfielddeclared in the view. Replacing the posted value with'; DROP TABLE book;--made the table ignore it and render the default order, without an error; replacingsortDir=1withabcmade the Ajax response fail withIllegalArgumentException: No SorderOrder matching value: abc(that spelling is in the message). Do not rely on that:RowServiceaccepts only the names in itsFIELDSmap, and the JPA model below binds every filter value as a parameter. Never concatenate a field name or a filter text into a query. -
Selection works on a lazy table when the model can find a row from its key: the check boxes selected rows on different pages, and the posted
14,999,abcproduced only the real book. -
The one-query variants of the documentation (
countreturning 0 andsetRowCount(page.getTotalElements())insideload, orrecalculateFirst) save the extra count query at the price of a known problem withfirst(PrimeFaces issue 1921, linked from the documentation). They were not run.
The sequence of the filter request, from that log:
Source: PrimeFaces 16 documentation: DataTable (Lazy Loading) (the call sequence is from the run’s log)
DefaultLazyDataModel
For a list that you can only fetch whole (a web service, a cache), DefaultLazyDataModel implements count and load for you: a supplier returns the list for the current filters, and the model filters and sorts by reflection on the column `field`s, then cuts the page. The documentation lists three reasons to prefer it
over binding a list: reflection is faster than restoring component state for every row (it states "up to 30%"), the list does not sit in a view-scoped bean, and a lambda can add filtering or sorting.
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.ArrayList;
import java.util.List;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.model.DefaultLazyDataModel;
import org.primefaces.model.LazyDataModel;
@Named("defaultLazy")
@RequestScoped
public class DefaultLazyBean {
@Inject
private RowService service;
private LazyDataModel<BookRow> model;
private List<BookRow> selected = new ArrayList<>();
@PostConstruct
void init() {
model = DefaultLazyDataModel.<BookRow>builder()
.valueSupplier(filterBy -> service.findAll())
.rowKeyProvider(BookRow::getId)
.build();
}
public LazyDataModel<BookRow> getModel() { return model; }
public List<BookRow> getSelected() { return selected; }
public void setSelected(List<BookRow> selected) { this.selected = selected; }
public String getSelectedTitles() { return selected.stream().map(BookRow::getTitle).toList().toString(); }
}
The page dlazy.xhtml is lazy.xhtml with #{defaultLazy.model}; it sorted by year, paged, filtered on the author (wells left the two Wells books) and kept the selection exactly as the hand-written model did. The supplier receives the active filters, so a lambda can hand them to the data source; the model then still filters, sorts and cuts the page itself from what the supplier returns, so unless the supplier narrows the query the saving is in the rendering and not in the data source (read from the class API and the documentation; the supplier calls were not counted). rowKeyProvider is required for selection.
JPALazyDataModel
With JPA the same idea is JPALazyDataModel. It builds the Criteria API queries from the sort and filter maps. It needs the entity class and a supplier for the EntityManager. The attribute names are those of the entity (title, year), not the column names (published_year).
package org.example.bookshelf.jpa;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.Serializable;
import java.math.BigDecimal;
@Entity
@Table(name = "book")
public class BookEntity implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String author;
private String genre;
@Column(name = "published_year")
private int year;
private BigDecimal price;
private boolean available;
protected BookEntity() {
}
public BookEntity(String title, String author, String genre, int year, BigDecimal price, boolean available) {
this.title = title;
this.author = author;
this.genre = genre;
this.year = year;
this.price = price;
this.available = available;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public String getGenre() { return genre; }
public int getYear() { return year; }
public BigDecimal getPrice() { return price; }
public boolean isAvailable() { return available; }
}
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.persistence.EntityManager;
import java.util.ArrayList;
import java.util.List;
import org.example.bookshelf.jpa.BookEntity;
import org.primefaces.model.JPALazyDataModel;
import org.primefaces.model.LazyDataModel;
@Named("jpaCatalogue")
@RequestScoped
public class JpaCatalogueBean {
@Inject
private EntityManager em;
private LazyDataModel<BookEntity> books;
private List<BookEntity> selected = new ArrayList<>();
@PostConstruct
void init() {
books = JPALazyDataModel.<BookEntity>builder()
.entityClass(BookEntity.class)
.entityManager(() -> em)
.caseSensitive(false)
.wildcardSupport(true)
.build();
}
public LazyDataModel<BookEntity> getBooks() { return books; }
public List<BookEntity> getSelected() { return selected; }
public void setSelected(List<BookEntity> selected) { this.selected = selected; }
public String getSelectedTitles() {
return selected.stream().map(BookEntity::getTitle).toList().toString();
}
}
<h:form id="form">
<p:dataTable id="books" value="#{jpaCatalogue.books}" var="book" paginator="true" rows="5"
selection="#{jpaCatalogue.selected}" sortMode="single" emptyMessage="No books match">
<p:column selectionBox="true" width="40"/>
<p:column headerText="Title" field="title" filterMatchMode="contains"/>
<p:column headerText="Author" field="author" filterMatchMode="contains"/>
<p:column headerText="Genre" field="genre" filterMatchMode="exact"/>
<p:column headerText="Year" field="year" filterMatchMode="gte"/>
</p:dataTable>
<p:commandButton id="show" value="Show selection" update="sel" process="@form"/>
<h:outputText id="sel" value="#{jpaCatalogue.selectedTitles}"/>
</h:form>
The scratch application ran this on Hibernate 7.4.5.Final with an in-memory H2 database (2.4.240); an application server provides the EntityManager with @PersistenceContext and a data source, and Tomcat needs plumbing such as the producer below, which is scratch code and carries no credentials (the persistence page of this section covers production configuration):
package org.example.bookshelf.jpa;
import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Initialized;
import jakarta.enterprise.context.RequestScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.Disposes;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import java.util.Map;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
/** Scratch-only plumbing: a resource-local EntityManager on an in-memory database, filled from the in-memory rows. */
@ApplicationScoped
public class JpaSupport {
private EntityManagerFactory emf;
@Inject
private RowService rows;
void start(@Observes @Initialized(ApplicationScoped.class) Object init) {
emf = Persistence.createEntityManagerFactory("bookshelf", Map.of(
"jakarta.persistence.jdbc.url", "jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1",
"hibernate.hbm2ddl.auto", "create-drop"));
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (BookRow r : rows.findAll()) {
em.persist(new BookEntity(r.getTitle(), r.getAuthor(), r.getGenre(), r.getYear(), r.getPrice(), r.isAvailable()));
}
em.getTransaction().commit();
em.close();
}
@Produces
@RequestScoped
EntityManager entityManager() {
return emf.createEntityManager();
}
void close(@Disposes EntityManager em) {
em.close();
}
@PreDestroy
void stop() {
if (emf != null) {
emf.close();
}
}
}
What the SQL log showed, after the seed data was stored:
-
The first render ran
select count(be1_0.id) from book be1_0and then the page query ending inoffset ? rows fetch first ? rows only. Sorting by year addedorder by be1_0.published_year(anddescon the second click). -
A filter on the title with
theran... where upper(be1_0.title) like ? escape ''for the count and for the page: the text is a bound parameter and the match is case-insensitive (caseSensitive(false)). -
With
wildcardSupport(true)the textDra*found "Dracula"; the characters*and%mean any characters, and?and_one character (documentation). -
The other builder methods are
filterEnricher(...)andsortEnricher(...)(add predicates or orders),additionalFilterMeta(...),rowKeyField(...)androwKeyConverter(...). The documentation listsfilterFunctionas unsupported: it has no effect with this model. It also notes that iterating the model queries page by page, so for a full export callload(0, count, sortBy, filterBy)once. -
The class was named
JpaLazyDataModelbefore 14.0.0 and was constructed directly; since then only the builder exists.
Lazy loading has its limits. With a lazy model, page the data on a stable sort (add the id as a last sort key), keep LazyInitializationException in mind when a row template reaches into an association (the persistence page covers fetching strategies and the lazy-loading pitfalls), and remember that filterFunction and globalFilterFunction
cannot be translated into SQL.
p:tree
p:tree shows hierarchical data. Its model is a org.primefaces.model.TreeNode<T>, the root, built from DefaultTreeNode objects (the constructor attaches a node to its parent). TreeNode is generic since 11.0.0 ("typed data objects"), so the examples hold a record, ShelfBean.Entry,
instead of the strings of the older material. A node has a type (the default is default), and p:treeNode components in the tree choose the markup and icons by that type: the Bookshelf tree has a genre folder type and a book leaf type.
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.event.NodeExpandEvent;
import org.primefaces.event.NodeSelectEvent;
import org.primefaces.event.NodeUnselectEvent;
import org.primefaces.model.CheckboxTreeNode;
import org.primefaces.model.DefaultTreeNode;
import org.primefaces.model.LazyDefaultTreeNode;
import org.primefaces.model.TreeNode;
/** The shelves of the library as a tree: genre folders containing books. */
@Named("shelf")
@ViewScoped
public class ShelfBean implements Serializable {
/** What a node carries. Serializable, because view-scoped beans are. */
public record Entry(String name, String kind, int year) implements Serializable {
}
@Inject
private RowService service;
private TreeNode<Entry> root;
private TreeNode<Entry> checkRoot;
private TreeNode<Entry> lazyRoot;
private TreeNode<Entry> selected;
private TreeNode<Entry>[] selectedNodes;
private String lastEvent = "none";
@PostConstruct
@SuppressWarnings("unchecked")
void init() {
root = build(new DefaultTreeNode<>(new Entry("Shelves", "root", 0)), false);
checkRoot = build(new CheckboxTreeNode<>(new Entry("Shelves", "root", 0)), true);
lazyRoot = new LazyDefaultTreeNode<>(new Entry("Shelves", "root", 0),
this::children, entry -> "book".equals(entry.kind()));
selectedNodes = new TreeNode[0];
}
private TreeNode<Entry> build(TreeNode<Entry> top, boolean checkbox) {
Map<String, TreeNode<Entry>> genres = new TreeMap<>();
for (BookRow row : service.findAll()) {
TreeNode<Entry> genre = genres.computeIfAbsent(row.getGenre(), g ->
checkbox ? new CheckboxTreeNode<>("genre", new Entry(g, "genre", 0), top)
: new DefaultTreeNode<>("genre", new Entry(g, "genre", 0), top));
if (checkbox) {
new CheckboxTreeNode<>("book", new Entry(row.getTitle(), "book", row.getYear()), genre);
} else {
new DefaultTreeNode<>("book", new Entry(row.getTitle(), "book", row.getYear()), genre);
}
}
top.setExpanded(true);
return top;
}
/** Called by the lazy node only when a node is expanded for the first time. */
private List<Entry> children(Entry parent) {
if ("root".equals(parent.kind())) {
return service.findAll().stream().map(BookRow::getGenre).distinct().sorted()
.map(g -> new Entry(g, "genre", 0)).toList();
}
return service.findAll().stream().filter(r -> r.getGenre().equals(parent.name()))
.map(r -> new Entry(r.getTitle(), "book", r.getYear())).toList();
}
public void onNodeSelect(NodeSelectEvent event) {
lastEvent = "select " + ((Entry) event.getTreeNode().getData()).name();
}
public void onNodeUnselect(NodeUnselectEvent event) {
lastEvent = "unselect " + ((Entry) event.getTreeNode().getData()).name();
}
public void onNodeExpand(NodeExpandEvent event) {
lastEvent = "expand " + ((Entry) event.getTreeNode().getData()).name();
}
public String getSelectedNames() {
StringBuilder names = new StringBuilder();
for (TreeNode<Entry> n : selectedNodes) {
names.append(n.getData().name()).append(';');
}
return names.toString();
}
public TreeNode<Entry> getRoot() { return root; }
public TreeNode<Entry> getCheckRoot() { return checkRoot; }
public TreeNode<Entry> getLazyRoot() { return lazyRoot; }
public TreeNode<Entry> getSelected() { return selected; }
public void setSelected(TreeNode<Entry> selected) { this.selected = selected; }
public TreeNode<Entry>[] getSelectedNodes() { return selectedNodes; }
/** The checkbox tree writes null when nothing is checked. */
@SuppressWarnings("unchecked")
public void setSelectedNodes(TreeNode<Entry>[] selectedNodes) {
this.selectedNodes = selectedNodes == null ? new TreeNode[0] : selectedNodes;
}
public String getLastEvent() { return lastEvent; }
}
<h:form id="form">
<p:tree id="shelves" value="#{shelf.root}" var="entry" selectionMode="single" selection="#{shelf.selected}"
dynamic="true" cache="true" filterBy="#{entry.name}" filterMatchMode="contains">
<p:ajax event="select" listener="#{shelf.onNodeSelect}" update=":form:last"/>
<p:ajax event="expand" listener="#{shelf.onNodeExpand}" update=":form:last"/>
<p:treeNode type="genre" expandedIcon="pi pi-folder-open" collapsedIcon="pi pi-folder">
<h:outputText value="#{entry.name}"/>
</p:treeNode>
<p:treeNode type="book" icon="pi pi-book">
<h:outputText value="#{entry.name} (#{entry.year})"/>
</p:treeNode>
</p:tree>
<p:tree id="checks" value="#{shelf.checkRoot}" var="entry" selectionMode="checkbox"
selection="#{shelf.selectedNodes}" propagateSelectionDown="true" propagateSelectionUp="true">
<p:treeNode type="genre"><h:outputText value="#{entry.name}"/></p:treeNode>
<p:treeNode type="book"><h:outputText value="#{entry.name}"/></p:treeNode>
</p:tree>
<p:commandButton id="go" value="Reserve" update="chosen"/>
<h:outputText id="chosen" value="#{shelf.selectedNames}"/>
<p:tree id="lazy" value="#{shelf.lazyRoot}" var="entry" dynamic="true">
<p:ajax event="expand" listener="#{shelf.onNodeExpand}" update=":form:last"/>
<p:treeNode><h:outputText value="#{entry.name}"/></p:treeNode>
</p:tree>
<h:outputText id="last" value="#{shelf.lastEvent}"/>
</h:form>
Behaviour, from the run:
-
Dynamic and static trees. With
dynamic="true"the tree first rendered only the seven genre nodes; the first click on a toggler postedform:shelves_expandNode=0and the server returned the four children of "Romance" (theexpandevent fired, withNodeExpandEvent.getTreeNode()). A tree withoutdynamicsends every node to the browser at once and toggles on the client: right for small trees, wasteful for large ones (documentation).cache="true"(the default) keeps the loaded children. -
Lazy nodes need a dynamic tree.
LazyDefaultTreeNodetakes the data, a function that lists the children’s data and a function that says whether a node is a leaf, and loads children when the node is first opened. Withoutdynamic="true"the tree ontree.xhtmlrendered all 31 nodes up front, which is the opposite of the intention; with it the seven genres appeared first and the books of "Adventure" were loaded on expansion. -
Selection.
selectionMode="single"binds aTreeNode;multipleandcheckboxbind aTreeNode[]. Clicking a label firedselectandNodeSelectEvent. In checkbox mode useCheckboxTreeNode: clicking the "Romance" box selected the folder and its four books, and the posted selection wasform:checks_selection=0_0,0_1,0_2,0_3,0(the row keys of the nodes);propagateSelectionDownandpropagateSelectionUp(bothtrue) control the cascade. -
The selection property can be set to
null. The checkbox tree wrotenullintosetSelectedNodeswhile rendering with nothing checked, so a getter that reads the array fails with aNullPointerException. Acceptnullin the setter, asShelfBeandoes. -
Filtering.
filterBy="#{entry.name}"withfilterMatchMode="contains"adds a filter box; typingemmaleft "Romance" and "Emma (1815)".filterFunctiontakes aTreeNode, the filter text and aLocale;filterModeislenient(default) orstrict. -
Drag and drop (
draggable,droppable,dragdropScopeto move nodes between trees,dragMode,dropRestrict,onDropto refuse a drop, and thedragdropevent withTreeDragDropEvent), a horizontalorientationand a context menu are described in the documentation and were not run.
p:treeTable
p:treeTable is a tree in the first column and ordinary columns after it. It takes the same TreeNode root and has var for the node data. It supports sorting, filtering, selection, paging, scrolling, editing and dynamic columns like the data table, and filterPrune="descendants" (documented for 16.0.0) hides the non-matching children of a matched node.
<h:form id="form">
<p:treeTable id="shelves" value="#{shelf.root}" var="entry" selectionMode="single" selection="#{shelf.selected}"
filterPrune="descendants">
<p:ajax event="select" listener="#{shelf.onNodeSelect}" update=":form:last"/>
<p:column headerText="Name" filterBy="#{entry.name}" filterMatchMode="contains" sortBy="#{entry.name}">
<h:outputText value="#{entry.name}"/>
</p:column>
<p:column headerText="Kind"><h:outputText value="#{entry.kind}"/></p:column>
<p:column headerText="Year" sortBy="#{entry.year}"><h:outputText value="#{entry.year == 0 ? '' : entry.year}"/></p:column>
</p:treeTable>
<h:outputText id="last" value="#{shelf.lastEvent}"/>
</h:form>
In the run the table showed the seven genres collapsed; a click on the toggler posted form:shelves_expand=0 and added the four books; typing emma in the filter of the "Name" column left "Romance" and "Emma" (with filterPrune="descendants"); clicking the "Name" header sorted the genres alphabetically and a second click reversed them. A sortBy on the table itself did not reorder
the first render, like the data table’s; use column sortBy. expandMode (children by default) decides whether an expansion re-renders only the children or the node too. cloneOnFilter is still in the 16.0.0 tag library although the 14.0.0 migration guide announced its removal; according to that guide, custom nodes are cloned for filtering through a three-argument constructor, a copy constructor or Cloneable.
p:dataView, p:dataGrid, p:dataList and p:dataScroller
When a table is the wrong shape, four components render a collection as cards or items.
-
p:dataViewshows each item as a card (grid) or a row (list). It needs ap:dataViewGridItemand ap:dataViewListItem, and has a toggle for the twolayoutvalues (listby default). -
p:dataGridandp:dataListare the older grid and list. The migration guide of 10.0.0 deprecated both in favour ofp:dataViewand their documentation pages still say so. They still work in 16.0.0. -
p:dataScrollerloads more items as the user scrolls or clicks a "load" element.
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.List;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.model.LazyDataModel;
@Named("views")
@RequestScoped
public class ViewsBean {
@Inject
private RowService service;
private LazyDataModel<BookRow> model;
private BookRow picked;
@PostConstruct
void init() {
model = new RowLazyModel(service);
}
public List<BookRow> getRows() { return service.findAll(); }
public LazyDataModel<BookRow> getModel() { return model; }
public BookRow getPicked() { return picked; }
public void setPicked(BookRow picked) { this.picked = picked; }
}
<h:form id="form">
<p:dataView id="dv" value="#{views.rows}" var="book" layout="grid" rows="6" paginator="true"
rowsPerPageTemplate="6,12" gridRowStyleClass="col-12 md:col-4">
<p:dataViewGridItem>
<p:panel header="#{book.title}"><h:outputText value="#{book.author}"/></p:panel>
</p:dataViewGridItem>
<p:dataViewListItem>
<h:outputText value="#{book.title} by #{book.author}"/>
</p:dataViewListItem>
</p:dataView>
<p:dataGrid id="dg" value="#{views.rows}" var="book" columns="3" rows="6" paginator="true">
<p:card><f:facet name="title">#{book.title}</f:facet>#{book.author}</p:card>
</p:dataGrid>
<p:dataList id="dl" value="#{views.rows}" var="book" type="ordered" rows="5" paginator="true">
<f:facet name="header">Titles</f:facet>
#{book.title}
</p:dataList>
<p:dataScroller id="ds" value="#{views.model}" var="book" chunkSize="5" mode="inline" scrollHeight="150" lazy="true">
<f:facet name="header">Scroll for more</f:facet>
<h:outputText value="#{book.title}"/>
</p:dataScroller>
</h:form>
Source: PrimeFaces 16 documentation: DataView, DataScroller, DataGrid and DataList
What the run showed:
-
The
p:dataViewin grid layout rendered six cards (rows="6"), with two layout buttons of which the grid one was active, and a paginator with the rows-per-page choices 6 and 12. The grid items took the PrimeFlex classes given ingridRowStyleClass; the flex grid is described on the PrimeFaces getting started page. -
p:dataGrid columns="3" rows="6"produced six cells andp:dataList type="ordered" rows="5"an ordered list of five titles.typeisunordered,orderedordefinition. -
p:dataScrollerover a lazy model. The scroller showed the first five items (chunkSize="5") in a box of 150 pixels (mode="inline",scrollHeight). Each further chunk was one Ajax request withform:ds_load=true&form:ds_offset=5; calling the widget’sload()gave ten items, and scrolling to the end of the box fifteen.lazydefaults totruefor aLazyDataModel. Withmode="document"(the default) the page’s own scroll bar is watched. Aloaderfacet replaces scrolling with a "view more" button, and aloadingfacet shows a placeholder meanwhile (documentation). -
The documentation says
p:dataViewsupports lazy models like the table does. The taglib has nolazyattribute for it, and this was not run.
p:schedule
p:schedule is a calendar based on FullCalendar (version 5 since PrimeFaces 10.0.0). Its model is a ScheduleModel of ScheduleEvent objects; DefaultScheduleModel and DefaultScheduleEvent (created with DefaultScheduleEvent.builder(), with java.time.LocalDateTime start and end) are the ready-made ones. The Bookshelf calendar shows loans and a reading club; clicking a day opens a dialog (dialogs are on the PrimeFaces panels, menus and dialogs page) to add one.
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Named;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.primefaces.event.SelectEvent;
import org.primefaces.event.schedule.ScheduleEntryMoveEvent;
import org.primefaces.model.DefaultScheduleEvent;
import org.primefaces.model.DefaultScheduleModel;
import org.primefaces.model.LazyScheduleModel;
import org.primefaces.model.ScheduleEvent;
import org.primefaces.model.ScheduleModel;
/** Loans and returns on a calendar. */
@Named("loans")
@ViewScoped
public class LoanBean implements Serializable {
private ScheduleModel model;
private ScheduleModel lazyModel;
private ScheduleEvent<?> event = new DefaultScheduleEvent<>();
private String lastEvent = "none";
@PostConstruct
void init() {
model = new DefaultScheduleModel();
LocalDate first = LocalDate.now().withDayOfMonth(1);
model.addEvent(DefaultScheduleEvent.builder().title("Emma due back")
.startDate(first.plusDays(4).atTime(10, 0)).endDate(first.plusDays(4).atTime(11, 0)).build());
model.addEvent(DefaultScheduleEvent.builder().title("Reading club")
.startDate(first.plusDays(9).atTime(18, 0)).endDate(first.plusDays(9).atTime(19, 30)).build());
lazyModel = new LazyScheduleModel() {
@Override
public void loadEvents(LocalDateTime start, LocalDateTime end) {
clear();
for (LocalDateTime d = start.toLocalDate().atStartOfDay(); d.isBefore(end); d = d.plusDays(7)) {
addEvent(DefaultScheduleEvent.builder().title("Weekly return day")
.startDate(d.withHour(9)).endDate(d.withHour(10)).build());
}
}
};
}
public void onDateSelect(SelectEvent<LocalDateTime> select) {
event = DefaultScheduleEvent.builder().startDate(select.getObject()).endDate(select.getObject().plusHours(1)).build();
lastEvent = "date " + select.getObject();
}
public void onEventSelect(SelectEvent<ScheduleEvent<?>> select) {
event = select.getObject();
lastEvent = "event " + event.getTitle();
}
public void onEventMove(ScheduleEntryMoveEvent move) {
lastEvent = "moved " + move.getScheduleEvent().getTitle() + " by " + move.getDayDelta() + "d " + move.getMinuteDelta() + "m";
}
public void save() {
if (event.getId() == null) {
model.addEvent(event);
} else {
model.updateEvent(event);
}
lastEvent = "saved " + event.getTitle();
event = new DefaultScheduleEvent<>();
}
public ScheduleModel getModel() { return model; }
public ScheduleModel getLazyModel() { return lazyModel; }
public ScheduleEvent<?> getEvent() { return event; }
public void setEvent(ScheduleEvent<?> event) { this.event = event; }
public String getLastEvent() { return lastEvent; }
}
<h:form id="form">
<p:growl id="msgs"/>
<p:schedule id="cal" value="#{loans.model}" widgetVar="cal" editable="true" view="dayGridMonth"
locale="en" timeZone="UTC" clientTimeZone="UTC" tooltip="true">
<p:ajax event="dateSelect" listener="#{loans.onDateSelect}" update="detail last" oncomplete="PF('dlg').show()"/>
<p:ajax event="eventSelect" listener="#{loans.onEventSelect}" update="detail last" oncomplete="PF('dlg').show()"/>
<p:ajax event="eventMove" listener="#{loans.onEventMove}" update="last"/>
</p:schedule>
<p:dialog widgetVar="dlg" header="Loan" modal="true">
<h:panelGrid id="detail" columns="2">
<p:outputLabel for="title" value="Title"/>
<p:inputText id="title" value="#{loans.event.title}" required="true"/>
<p:outputLabel for="from" value="From"/>
<p:datePicker id="from" value="#{loans.event.startDate}" showTime="true" pattern="yyyy-MM-dd HH:mm"/>
<p:outputLabel for="to" value="To"/>
<p:datePicker id="to" value="#{loans.event.endDate}" showTime="true" pattern="yyyy-MM-dd HH:mm"/>
<p:commandButton id="save" value="Save" action="#{loans.save}" update="msgs last"
oncomplete="if (!args.validationFailed) { PF('cal').update(); PF('dlg').hide(); }"/>
</h:panelGrid>
</p:dialog>
<h:outputText id="last" value="#{loans.lastEvent}"/>
<p:schedule id="lazy" value="#{loans.lazyModel}" view="listMonth"/>
</h:form>
What the run showed:
-
The month view listed the two seeded events. Loading is itself an Ajax request:
form:cal_event=true&form:cal_start=2026-09-27T00:00:00.000Z&form:cal_end=2026-11-08T00:00:00.000Z, asked by the widget for the visible range; withLazyScheduleModelthe server’sloadEvents(start, end)is called with that range, so the weekly "return day" model produced five events in the list view of October. -
Clicking a day posted
form:cal_selectedDate=2026-10-11T00:00:00.000Zand the listeneronDateSelect(SelectEvent<LocalDateTime>)prepared the dialog’s event; clicking an event postedform:cal_selectedEventId=<uuid>, an id that PrimeFaces generates for each event (getId()is "used internally, auto generated" in the documentation), andonEventSelectreceived the event (SelectEvent<ScheduleEvent<?>>). The events aredateSelect,dateDblSelect,eventSelect,eventDblSelect,eventMove(ScheduleEntryMoveEvent),eventResize,viewChangeandrangeSelect. -
"Save" added "Club night" and the oncomplete handler called
PF('cal').update(). Update the schedule through its widget, not throughupdate=: the calendar is built in the browser, and a normal partial update rebuilds its DOM (documentation);update()asks the server for the model again as JSON. -
Time zones. The first schedule had
clientTimeZone="UTC"and asked for2026-09-27T00:00:00.000Z; the second, with the default, asked for2026-09-30T22:00:00.000Zbecause the browser (and the server) were in Central European Summer Time (UTC+2). The 11.0.0 migration guide says to setclientTimeZoneto the server’s zone or toUTC, since FullCalendar is time-zone aware.timeZoneis the server-side zone for converting. -
The header is configured with
leftHeaderTemplate,centerHeaderTemplateandrightHeaderTemplate(the Schedule page of the documentation names the middle onecenterTemplate, but the 16.0.0 tag library hascenterHeaderTemplate),viewtakes the nine FullCalendar views of the documentation (dayGridMonth,timeGridWeek,listMonthand so on), andextendernames a JavaScript function that sets FullCalendar options the component does not expose.locale,showWeekends,slotDuration,minTime/maxTime,tooltip(shows the event’s description) andselectableare among the others;escape(defaulttrue) escapes the tooltip text. -
An event’s
urlmakes it a link that opens instead of firingeventSelect;noOpener(taglib) prevents the opened page from reachingwindow.opener. Do not build an event URL from user input.
p:dataExporter
p:dataExporter downloads the contents of a p:dataTable (or p:treeTable) as a file. It is a behavior: nest it in a command component, name the target table and a type. The supported types are those registered by PrimeFaces: for a data table xls, xlsx, xlsxstream, pdf, csv, csvmemory, xml and xmlmemory; for a tree table the same without the two memory types (read in the 16.0.0 jar). The Excel and PDF types need libraries you add yourself:
PrimeFaces declares Apache POI (poi, poi-ooxml) and OpenPDF as provided dependencies in its 16.0.0 POM, with the versions 5.5.1 and 1.4.2 in its parent POM.
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>1.4.2</version>
</dependency>
package org.example.bookshelf.web;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import org.example.bookshelf.model.BookRow;
import org.example.bookshelf.service.RowService;
import org.primefaces.component.export.ExcelOptions;
import org.primefaces.component.export.PDFOptions;
@Named("export")
@ViewScoped
public class ExportBean implements Serializable {
@Inject
private RowService service;
private List<BookRow> rows;
private List<BookRow> selected = new ArrayList<>();
private ExcelOptions excelOptions;
private PDFOptions pdfOptions;
@PostConstruct
void init() {
rows = new ArrayList<>(service.findAll().subList(0, 6));
// Text typed by users can start with a character a spreadsheet treats as a formula.
rows.add(new BookRow(101, "=1+1", "+cmd", "Gothic", 2000, "1.00", true));
rows.add(new BookRow(102, "@SUM(A1:A2)", "-2+3", "Gothic", 2001, "1.00", true));
rows.add(new BookRow(103, "Say \"hi\", then, go", "Line\nbreak", "Gothic", 2002, "1.00", true));
excelOptions = new ExcelOptions();
excelOptions.setFacetBgColor("#d6e4f0");
excelOptions.setFacetFontStyle("BOLD");
excelOptions.setStronglyTypedCells(true);
pdfOptions = new PDFOptions();
}
/** Runs after the workbook has the data; a place to add a sheet title, widths and so on. */
public void postProcessXls(Object document) {
org.apache.poi.ss.usermodel.Workbook workbook = (org.apache.poi.ss.usermodel.Workbook) document;
workbook.getSheetAt(0).setColumnWidth(0, 12000);
}
public List<BookRow> getRows() { return rows; }
public List<BookRow> getSelected() { return selected; }
public void setSelected(List<BookRow> selected) { this.selected = selected; }
public ExcelOptions getExcelOptions() { return excelOptions; }
public PDFOptions getPdfOptions() { return pdfOptions; }
}
<h:form id="form">
<p:dataTable id="books" value="#{export.rows}" var="book" rowKey="#{book.id}" selection="#{export.selected}"
paginator="true" rows="5">
<p:column selectionBox="true" exportable="false"/>
<p:column headerText="Title" field="title"/>
<p:column headerText="Author" field="author"/>
<p:column headerText="Price" exportValue="#{book.price} EUR">
<h:outputText value="#{book.price}"/>
</p:column>
<p:column headerText="Open" exportable="false">
<p:commandButton value="Details" type="button"/>
</p:column>
</p:dataTable>
<p:commandButton id="csv" value="CSV" ajax="false"><p:dataExporter type="csv" target="books" fileName="catalogue"/></p:commandButton>
<p:commandButton id="csvAjax" value="CSV (default Ajax)"><p:dataExporter type="csv" target="books" fileName="ajax"/></p:commandButton>
<p:commandButton id="csvPage" value="CSV page" ajax="false"><p:dataExporter type="csv" target="books" fileName="page" pageOnly="true"/></p:commandButton>
<p:commandButton id="csvSel" value="CSV selection" ajax="false"><p:dataExporter type="csv" target="books" fileName="sel" selectionOnly="true"/></p:commandButton>
<p:commandButton id="xml" value="XML" ajax="false"><p:dataExporter type="xml" target="books" fileName="catalogue"/></p:commandButton>
<p:commandButton id="xlsx" value="XLSX" ajax="false"><p:dataExporter type="xlsx" target="books" fileName="catalogue" options="#{export.excelOptions}" postProcessor="#{export.postProcessXls}"/></p:commandButton>
<p:commandButton id="xls" value="XLS" ajax="false"><p:dataExporter type="xls" target="books" fileName="catalogue"/></p:commandButton>
<p:commandButton id="pdf" value="PDF" ajax="false"><p:dataExporter type="pdf" target="books" fileName="catalogue" options="#{export.pdfOptions}"/></p:commandButton>
</h:form>
Source: PrimeFaces 16 documentation: DataExporter and Apache POI
What the run showed (every type, the page and selection variants and the default Ajax download were repeated on MyFaces with the same results):
-
Every type produced a file with the right content type and
Content-Disposition: attachment;filename="catalogue.csv"; filename*=UTF-8''catalogue.csv; the PDF opened as a one-page PDF 1.5, thexlsas a binary Excel file and thexlsxas an OOXML workbook. A button withoutajax="false"also downloaded (csvAjax): since PrimeFaces 11 the exporter works with Ajax, as the documentation says. -
pageOnly="true"exported the five rows of the current page,selectionOnly="true"the one selected row; the export covers the whole table otherwise, also the rows that paging hides.exportable="false"on a column removed the selection and "Open" columns, andexportValue="#{book.price} EUR"replaced the cell’s content ("9.90 EUR").exportFunctionandexportHeaderValueandexportFooterValueare the other per-column hooks. -
The XML export named its root after the table id and each row after the
var(<books><book><title>...);exportTagandexportRowTagon the table andexportTagon a column rename them. -
The CSV file starts with a UTF-8 byte order mark, wraps every cell in double quotes, doubles embedded quotes and keeps a line break inside a quoted cell.
-
optionstakes anExcelOptions,PDFOptionsorCSVOptions,preProcessorandpostProcessorget the document (a POIWorkbookfor the Excel types: thepostProcessXlsmethod above cast it toorg.apache.poi.ss.usermodel.Workbookand widened a column in thexlsxfile; the OpenPDF document object for PDF (documentation); aStringBuilderforcsvmemory).
CSV and spreadsheet formulas. This is the point the file download page left open, and the run answered it: the PrimeFaces 16.0.0 CSV exporter does not neutralise formulas. The rows with the titles =1+1, @SUM(A1:A2) and the author -2+3 were written verbatim as "=1+1", "@SUM(A1:A2)" and "-2+3", and a spreadsheet may evaluate such a cell when the file is
opened (OWASP, CSV injection). The xlsx workbook was inspected as well: every cell was stored as a shared string and no formula element appeared, so a spreadsheet application reads them as text (not tried in Excel). For CSV, replace the built-in exporter once at start-up with one that defuses cell text, which the documented DataExporters.register allows (the log said Exporter org.primefaces.component.datatable.export.DataTableCSVExporter of type csv has been replaced by org.example.bookshelf.web.SafeCsvExporter):
package org.example.bookshelf.web;
import org.primefaces.component.datatable.export.DataTableCSVExporter;
/** The built-in CSV exporter, with cell text that a spreadsheet could read as a formula defused. */
public class SafeCsvExporter extends DataTableCSVExporter {
@Override
protected String escapeQuotes(String value) {
return super.escapeQuotes(defuse(value));
}
static String defuse(String value) {
if (value != null && !value.isEmpty() && "=+-@\t\r".indexOf(value.charAt(0)) >= 0) {
return "'" + value;
}
return value;
}
}
package org.example.bookshelf.web;
import jakarta.faces.application.Application;
import jakarta.faces.event.AbortProcessingException;
import jakarta.faces.event.SystemEvent;
import jakarta.faces.event.SystemEventListener;
import org.primefaces.component.datatable.DataTable;
import org.primefaces.component.export.DataExporters;
/** Replaces the "csv" exporter of data tables once, when the application starts. */
public class ExporterRegistration implements SystemEventListener {
@Override
public void processEvent(SystemEvent event) throws AbortProcessingException {
DataExporters.register(DataTable.class, SafeCsvExporter.class, "csv");
}
@Override
public boolean isListenerForSource(Object source) {
return source instanceof Application;
}
}
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee" version="4.1">
<application>
<system-event-listener>
<system-event-listener-class>org.example.bookshelf.web.ExporterRegistration</system-event-listener-class>
<system-event-class>jakarta.faces.event.PostConstructApplicationEvent</system-event-class>
</system-event-listener>
</application>
</faces-config>
With it the same rows came out as "'=1+1","'+cmd" and "'@SUM(A1:A2)","'-2+3". The prefix is the OWASP recommendation; the cost is that a genuine negative number written as text, such as the cell -5, also gets the apostrophe, so export numbers as numeric cells in xlsx and keep this for text-like CSV data (the
download page discusses the number case). The built-in exporters were not changed for the other types.
Custom formats work the same way: extend DataTableExporter or TreeTableExporter and register the class with the type name. The 13.0.0 migration guide notes that the exporter API was refactored and a custom exporter needs this registration; the exporter attribute of p:dataExporter was deprecated in 13.0.0 and is not in the 16.0.0 tag library.
Ajax Events of the Data Components
The names that p:ajax event="..." accepts, read with getEventNames() from the 16.0.0 jar:
| Component | Events | Event classes |
|---|---|---|
|
|
|
|
|
|
|
|
the same, and |
|
|
|
|
|
|
|
|
none |
|
|
|
A table event that nobody listens to costs no request. page, sort and filter need no p:ajax to work; the p:ajax child is only for running a server method afterwards (the update of such a child re-renders the named components, here the "last event" text). Besides the behavior events,
org.primefaces.event.data.PostSortEvent, PostFilterEvent and PostPageEvent can be observed with f:event (documentation).
Mojarra and MyFaces Compared
Every example ran on both implementations, driven by the same browser scripts (including the call log of the lazy model and the forged-request checks), and the results were identical. What differed:
| Observation | Mojarra 4.1.17 | MyFaces 4.1.4 |
|---|---|---|
Generated component ids |
|
|
Order of the entries in the |
not the column order ( |
the order of the columns |
Whitespace in generated markup of |
a space between label and value |
none |
Sorting, filtering, paging, selection, editing, expansion, lazy |
identical results |
identical results |
Do not depend on the order of the sort and filter maps for anything but the priority PrimeFaces assigns; read SortMeta.getPriority(). PrimeFaces itself is the same code on both, and the 16.0.0 jar loaded without any implementation-specific configuration.
What Changed per Version
From the PrimeFaces migration guides (the guide for each release is linked in the references); only the items about the components of this page are listed.
| Version | Change |
|---|---|
10.0.0 |
|
11.0.0 |
|
12.0.0 |
Row select mode |
13.0.0 |
The exporter API was refactored and custom exporters are registered with |
14.0.0 |
|
15.0.0 |
|
16.0.0 |
|
The Faces 4.1 changes to UIData and ui:repeat are on the data tables page. Faces 5.0 is upcoming, and nothing about it was checked for these components.
Where the Books Are Outdated
The PrimeFaces Cookbook by Mert Çalışkan and Oleg Varaksin (second edition, Packt, 2015) teaches the data components with the PrimeFaces 5 API. Its chapter on data iteration is still a good tour of what the components are for. The code needs updating:
-
Selection. The book turns on selection with a
selectionModeattribute on the table and uses<p:column selectionMode="single"/>for a radio or checkbox column. Since 13.0.0 the mode is deduced from the type of theselectionproperty, the column attribute isselectionBox(14.0.0), the attributes were renamed, and a non-lazy table needs arowKey(11.0.0); the run showed the error when it is missing. -
Lazy loading. The book’s
load(first, pageSize, sortField, sortOrder, filters)is gone and thesetRowCountcall is no longer needed (the method still exists). The model implementscountand aloadthat receives maps ofSortMetaandFilterMeta;lazy="true"is detected.JPALazyDataModelandDefaultLazyDataModelreplace the hand-written Criteria API models the book recommends. -
Sorting and filtering.
sortBy="#{car.name}"withsortFieldorsortOrderon the table, and theSortOrderargument, were replaced in 10.0.0 byfield,sortOrderandsortPriorityon the column and bySortMeta; sort functions take three arguments since 14.0.0. -
Schedule. The book’s
LazyScheduleModel.loadEvents(Date, Date)andnew DefaultScheduleEvent(title, Date, Date)usejava.util.Date. In 16.0.0 the method takesLocalDateTimeand events are built withDefaultScheduleEvent.builder()(both checked in the jar); the underlying FullCalendar is version 5 since 10.0.0, so the CSS andextenderoptions differ. -
Export. The book lists iText 2.1.7 and Apache POI 3.7 as the optional libraries. PrimeFaces 16 expects POI 5.5.1 and OpenPDF 1.4.2, the exporters are registered with
DataExporters, and theexporterattribute is gone. -
Icons and look.
ui-icon-*classes (jQuery UI) in the book’s buttons and tree nodes give way to PrimeIcons (pi pi-folder), andp:calendartop:datePicker, as the schedule dialog above uses. -
Dependencies and platform. The book’s examples target Servlet 3 containers and
javax.faces; PrimeFaces 16 needs Jakarta Faces 4.0 or later. -
Trees. The book builds trees with raw
new DefaultTreeNode("Root", null); they still compile butTreeNode<T>is generic (11.0.0), and the checkbox selection usesCheckboxTreeNode.
The components of PrimeFaces Extensions that Jonna’s book describes (including a spreadsheet-like table and its own exporter) are not covered here; the PrimeFaces Extensions page of this section says which of them remain. Where the book and the PrimeFaces 16 documentation disagree, the documentation and the jar win.
Source: Mert Çalışkan and Oleg Varaksin, PrimeFaces Cookbook, Second Edition (Packt, 2015, ISBN 978-1-78439-342-7); Sudheer Jonna, Learning PrimeFaces Extensions Development (Packt, March 2014, ISBN 978-1-78398-324-7)
Pitfalls
-
Binding a getter that builds the list. The table sorts the list in place, and a getter that returns a new list on each call defeats the sort and runs the query for every evaluation. Load once in
@PostConstructor use a lazy model. -
Immutable lists.
List.ofandStream.toList()fail on the first sort withUnsupportedOperationException. Copy into anArrayList. -
Two tables on one list. They share the in-place sort.
-
fieldalso enables filtering. Usefilterable="false"where a filter box makes no sense. -
A missing
rowKey. HTTP 500 on the first request of a table withselectionorrowExpansion. ArowKeythat contains a comma breaks the selection. -
Wrong event name for the selection column.
rowSelectis for row clicks,rowSelectCheckboxfor checkboxes. -
A bare
sortByon the table. It did not sort the first render; use the column attributes or aList<SortMeta>, and name only fields that exist as columns. -
A lazy model called twice per request. A filter request loads in Apply Request Values and again in Render Response; a
process="@form"button loads once. Keepcountandloadcheap and free of side effects. -
Trusting the browser. Selection keys, row indexes of edit requests and event ids come from the browser. Look up each row through the service and check the user’s rights; never place a posted field name or filter text in a query.
-
CSV export of user text. The built-in CSV exporter writes
=...,@...,-...and+...cells verbatim; register an exporter that defuses them. -
treeselectionnull. The checkbox tree writesnullto its selection property; accept it. -
A lazy tree without
dynamic="true". It renders every node. -
A schedule updated with
update=. UsePF('widget').update()after changing the model, and setclientTimeZone. -
Cell editing skips validation. The documentation says
requiredand Bean Validation do not apply in cell mode. -
Keeping a large model in a long scope. A
@ViewScopedor session bean that holds thousands of rows is serialised with the view or the session; hold only what the user is editing. -
Row grouping with a lazy table. Not supported (documentation).
-
virtualScroll. Not verified here. In the run its table showed no rows in the headless check, so test it with your model before using it;liveScrollwithscrollRowsloaded 6, 12, 18 and 24 rows on scrolling.
References
-
Çalışkan, Varaksin: PrimeFaces Cookbook, Second Edition, Packt, 2015, publisher page
-
Jonna: Learning PrimeFaces Extensions Development, Packt, 2014, publisher page