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

jakarta.faces.partial.execute=form:books, form:books_sorting=true, form:books_sortKey=<client id of the column>, form:books_sortDir=1 (ascending), -1 (descending) or 0 (unsorted), plus _skipChildren=true and _encodeFeature=true

sort

Type in a filter field

form:books_filtering=true, the filter values as form:books:<column id>:filter=text, form:books:globalFilter for the global filter

filter

Click a page link

form:books_pagination=true, form:books_first=5, form:books_rows=5

page

Select a row

form:books_selection=1,3,7 (the row keys, comma separated) or @all after "select all"; the event itself carries jakarta.faces.behavior.event=rowSelect (or rowSelectCheckbox)

rowSelect, rowUnselect, rowSelectCheckbox, rowUnselectCheckbox, toggleSelect

Expand a row

form:books_rowExpansion=true, form:books_expandedRowIndex=0, form:books_expandedRowKey=1

rowToggle

Save an edited row

form:books_rowEditIndex=0, form:books_rowEditAction=save, and only the inputs of that row (form:books:0:title=...)

rowEdit, rowEditCancel, rowEditInit

Edit a cell

form:books_cellInfo=0,1 (row index, column index) and the cell’s input

cellEdit, cellEditInit, cellEditCancel

Scroll a lazy p:dataScroller

form:ds_load=true, form:ds_offset=5

load

Expand a tree node

form:shelves_expandNode=0 (the row key of the node)

expand, collapse, select, unselect

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.

model/BookRow.java
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);
    }
}
service/RowService.java
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:

web/TableBean.java
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:

basic.xhtml (content of h:body)
<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=5 and 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 with f:convertNumber.

  • field does 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 with sortable="false" or filterable="false", or use headerText with a body (as the price column does) if the column should be plain.

  • paginatorTemplate accepts the keys {FirstPageLink}, {PreviousPageLink}, {PageLinks}, {NextPageLink}, {LastPageLink}, {CurrentPageReport}, {RowsPerPageDropdown}, {JumpToPageDropdown} and {JumpToPageInput}; currentPageReportTemplate accepts {currentPage}, {totalPages}, {totalRecords}, {startRecord} and {endRecord}; paginatorPosition is top, bottom or both (the 16.0.0 taglib says the default is bottom, the documentation text says both; 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, emptyMessage and the emptyMessage facet, which accepts markup.

  • clientCache="true" makes the table fetch the next page in the background so that "next" is instant (documentation); reflow="true" and responsivePriority on 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.

sortfilter.xhtml (content of h:body)
<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 dataLocale is the locale used in features such as sorting, and caseSensitiveSort is false by 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.rows reorders that very ArrayList; "unsorted" can only stop sorting, it cannot restore the original order of a plain list. Two consequences were observed: a List that is immutable (List.of(...), Stream.toList()) fails the first render with UnsupportedOperationException raised in SortFeature.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 one SortMeta for each sorted column, keyed by the client id of the column, not by the field name (form:books:j_idt5 on Mojarra, form:books:j_id_9 on MyFaces). Read the field from SortMeta.getField().

  • A bare sortBy="#{book.genre}" on the table did not sort the initial render in 16.0.0 (checked with and without headerRow); the 10.0.0 migration guide says DataTable#sortBy expects a single SortMeta or a collection of them. Use the column attributes or a List<SortMeta> built with SortMeta.builder().field("genre").order(SortOrder.ASCENDING).priority(1).build(). A SortMeta names its column by field, so a column with that field must exist: a SortMeta for year on a table without such a column failed with FacesException: No column with field 'year' or columnKey 'null' has been found.

  • sortFunction takes 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, #sortOrder and #sortFunction were removed in 10.0.0 in favour of the column attributes; a sortOrder written 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

p:inputText id="globalFilter" in the table’s header facet, onkeyup="PrimeFaces.debounce(() => PF('booksTable').filter())"; the table needs a widgetVar

Typing jane kept the five rows with "Jane" in any column; the request carried form:books:globalFilter=jane and the FilterMeta map had an entry keyed globalFilter

Text filter

field="author" filterMatchMode="contains"

dick kept the three Dickens books. The default filterMatchMode is startsWith

filter facet, exact

p:selectOneMenu onchange="PF('booksTable').filter()" with filterMatchMode="exact"

Choosing "Gothic" kept the five Gothic books

filter facet, comparable

p:spinner with f:converter converterId="jakarta.faces.Integer" and filterMatchMode="gte"

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.

select.xhtml (content of h:body)
<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 (a p:commandButton, which processes @all by default) received three books, so the selection survives paging. This is why a rowKey must 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 default selectionPageOnly="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 fires rowSelectCheckbox and rowUnselectCheckbox (a radio column fires rowSelectRadio by its name; not run); the header checkbox fires toggleSelect. With event="rowSelect" and a checkbox column the listener was never called and the page showed "none" until the events were changed. The listener receives a SelectEvent<BookRow> (getObject()) or an UnselectEvent<BookRow>; the setter of the selection ran in Apply Request Values and the listener in Invoke Application.

  • A table without rowKey fails. A non-lazy table with selection and no rowKey returned HTTP 500 with UnsupportedOperationException: DataTable#rowKey must be defined for component form:books. The 11.0.0 migration guide introduced the requirement (and for p: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, through getRowData(String), and the RowLazyModel below returns null for 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.

edit.xhtml (content of h:body)
<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=save and only the inputs of that row (form:books:0:title=...); the listener ran, RowService.save stored the change, and the growl said "Saved". A title containing <b>x</b> came back as the literal text, because h:outputText escapes.

  • 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 a label so the message names the field.

  • A cell edit posted form:books_cellInfo=0,1 and the input, and the listener saw Jane Austen -> J. Austen through getOldValue() and getNewValue(); getRowData() is the row object, already changed.

  • The documentation warns that required and Bean Validation do not apply in cell mode (it links a PrimeFaces discussion). That was not run; validate in the listener and add a FacesMessage.

  • rowEditMode and cellEditMode (eager or lazy) 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.

expand.xhtml (content of h:body)
<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>

What the run showed:

  • The first expansion posted form:books_rowExpansion=true&form:books_expandedRowIndex=0&form:books_expandedRowKey=1, the listener received a ToggleEvent (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 carried ui-helper-hidden. Unchecking "Author" hid its header cell and column.

  • The toggle event of p:columnToggler delivers a ColumnToggleEvent (a ToggleEvent that also has getColumn()), changed from a plain ToggleEvent in 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:

group.xhtml (content of h:body)
<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>
web/GroupBean.java
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 from p:column children (with colspan) and expandable="true" makes the group collapsible (expanded sets its initial state).

  • p:summaryRow rendered after each group; book inside it is a row of the group, so group.totalFor(book.genre) gave 36.10 for Adventure (12.00 + 7.60 + 8.10 + 8.40) and 6.70 for Fantasy. Its listener attribute is an alternative for computing totals before the row renders (documentation).

  • groupRow="true" produced rowspan cells (Adventure[rowspan=4]). The rowspan counts only the rows on the page: with rows="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, Title spanning two rows and Details two columns. The type values are header, footer, and, for frozen tables, frozenHeader, frozenFooter, scrollableHeader and scrollableFooter (documentation).

  • p:columns with var="col" made four columns from the Col records, with field="#{col.property}", sortBy="#{book[col.property]}" and the body #{book[col.property]}; header text came from #{col.header}. columnIndexVar names the column index.

  • A scrollable table got the style class ui-datatable-scrollable ui-datatable-frozencolumn. scrollable, scrollHeight, scrollWidth, frozenRows, frozenColumns and frozenColumnsAlignment are the attributes. Simple scrolling renders every row; liveScroll="true" and virtualScroll="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

int count(Map<String, FilterMeta> filterBy)

the active filters; return the number of rows that match, for the paginator

List<T> load(int first, int pageSize, Map<String, SortMeta> sortBy, Map<String, FilterMeta> filterBy)

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.

web/RowLazyModel.java
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;
    }
}
web/LazyBean.java
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();
    }
}
lazy.xhtml (content of h:body)
<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 wells in the author filter left the two H. G. Wells books. All of it ran through RowLazyModel.load.

  • When the model is called. Logging the phase inside count and load showed: the first GET calls count and load once, 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 with process="@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 keep count and load cheap 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’s field declared in the view. Replacing the posted value with '; DROP TABLE book;-- made the table ignore it and render the default order, without an error; replacing sortDir=1 with abc made the Ajax response fail with IllegalArgumentException: No SorderOrder matching value: abc (that spelling is in the message). Do not rely on that: RowService accepts only the names in its FIELDS map, 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,abc produced only the real book.

  • The one-query variants of the documentation (count returning 0 and setRowCount(page.getTotalElements()) inside load, or recalculateFirst) save the extra count query at the price of a known problem with first (PrimeFaces issue 1921, linked from the documentation). They were not run.

The sequence of the filter request, from that log:

sequenceDiagram autonumber participant B as Browser participant L as Faces lifecycle participant T as DataTable participant M as RowLazyModel participant S as RowService B->>L: POST filter (execute=form:books) L->>T: Apply Request Values, decode the feature and the selection T->>M: count(previous filters), load(first=5, previous sort) M->>S: count and find L->>T: Process Validations (a p:ajax filter listener would run here) L->>T: Render Response with the new filters, first back to 0 T->>M: count(new filters), load(first=0, same sort) M->>S: count and find T-->>B: partial response, new body and paginator

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.

web/DefaultLazyBean.java
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).

jpa/BookEntity.java
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; }
}
web/JpaCatalogueBean.java
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();
    }
}
jpa.xhtml (content of h:body)
<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):

jpa/JpaSupport.java
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_0 and then the page query ending in offset ? rows fetch first ? rows only. Sorting by year added order by be1_0.published_year (and desc on the second click).

  • A filter on the title with the ran ... 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 text Dra* found "Dracula"; the characters * and % mean any characters, and ? and _ one character (documentation).

  • The other builder methods are filterEnricher(...) and sortEnricher(...) (add predicates or orders), additionalFilterMeta(...), rowKeyField(...) and rowKeyConverter(...). The documentation lists filterFunction as unsupported: it has no effect with this model. It also notes that iterating the model queries page by page, so for a full export call load(0, count, sortBy, filterBy) once.

  • The class was named JpaLazyDataModel before 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.

web/ShelfBean.java
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; }
}
tree.xhtml (content of h:body)
<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 posted form:shelves_expandNode=0 and the server returned the four children of "Romance" (the expand event fired, with NodeExpandEvent.getTreeNode()). A tree without dynamic sends 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. LazyDefaultTreeNode takes 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. Without dynamic="true" the tree on tree.xhtml rendered 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 a TreeNode; multiple and checkbox bind a TreeNode[]. Clicking a label fired select and NodeSelectEvent. In checkbox mode use CheckboxTreeNode: clicking the "Romance" box selected the folder and its four books, and the posted selection was form:checks_selection=0_0,0_1,0_2,0_3,0 (the row keys of the nodes); propagateSelectionDown and propagateSelectionUp (both true) control the cascade.

  • The selection property can be set to null. The checkbox tree wrote null into setSelectedNodes while rendering with nothing checked, so a getter that reads the array fails with a NullPointerException. Accept null in the setter, as ShelfBean does.

  • Filtering. filterBy="#{entry.name}" with filterMatchMode="contains" adds a filter box; typing emma left "Romance" and "Emma (1815)". filterFunction takes a TreeNode, the filter text and a Locale; filterMode is lenient (default) or strict.

  • Drag and drop (draggable, droppable, dragdropScope to move nodes between trees, dragMode, dropRestrict, onDrop to refuse a drop, and the dragdrop event with TreeDragDropEvent), a horizontal orientation and 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.

ttable.xhtml (content of h:body)
<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:dataView shows each item as a card (grid) or a row (list). It needs a p:dataViewGridItem and a p:dataViewListItem, and has a toggle for the two layout values (list by default).

  • p:dataGrid and p:dataList are the older grid and list. The migration guide of 10.0.0 deprecated both in favour of p:dataView and their documentation pages still say so. They still work in 16.0.0.

  • p:dataScroller loads more items as the user scrolls or clicks a "load" element.

web/ViewsBean.java
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; }
}
views.xhtml (content of h:body)
<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>

What the run showed:

  • The p:dataView in 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 in gridRowStyleClass; the flex grid is described on the PrimeFaces getting started page.

  • p:dataGrid columns="3" rows="6" produced six cells and p:dataList type="ordered" rows="5" an ordered list of five titles. type is unordered, ordered or definition.

  • p:dataScroller over 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 with form:ds_load=true&form:ds_offset=5; calling the widget’s load() gave ten items, and scrolling to the end of the box fifteen. lazy defaults to true for a LazyDataModel. With mode="document" (the default) the page’s own scroll bar is watched. A loader facet replaces scrolling with a "view more" button, and a loading facet shows a placeholder meanwhile (documentation).

  • The documentation says p:dataView supports lazy models like the table does. The taglib has no lazy attribute 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.

web/LoanBean.java
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; }
}
sched.xhtml (content of h:body)
<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; with LazyScheduleModel the server’s loadEvents(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.000Z and the listener onDateSelect(SelectEvent<LocalDateTime>) prepared the dialog’s event; clicking an event posted form:cal_selectedEventId=<uuid>, an id that PrimeFaces generates for each event (getId() is "used internally, auto generated" in the documentation), and onEventSelect received the event (SelectEvent<ScheduleEvent<?>>). The events are dateSelect, dateDblSelect, eventSelect, eventDblSelect, eventMove (ScheduleEntryMoveEvent), eventResize, viewChange and rangeSelect.

  • "Save" added "Club night" and the oncomplete handler called PF('cal').update(). Update the schedule through its widget, not through update=: 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 for 2026-09-27T00:00:00.000Z; the second, with the default, asked for 2026-09-30T22:00:00.000Z because the browser (and the server) were in Central European Summer Time (UTC+2). The 11.0.0 migration guide says to set clientTimeZone to the server’s zone or to UTC, since FullCalendar is time-zone aware. timeZone is the server-side zone for converting.

  • The header is configured with leftHeaderTemplate, centerHeaderTemplate and rightHeaderTemplate (the Schedule page of the documentation names the middle one centerTemplate, but the 16.0.0 tag library has centerHeaderTemplate), view takes the nine FullCalendar views of the documentation (dayGridMonth, timeGridWeek, listMonth and so on), and extender names a JavaScript function that sets FullCalendar options the component does not expose. locale, showWeekends, slotDuration, minTime/maxTime, tooltip (shows the event’s description) and selectable are among the others; escape (default true) escapes the tooltip text.

  • An event’s url makes it a link that opens instead of firing eventSelect; noOpener (taglib) prevents the opened page from reaching window.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>
web/ExportBean.java
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; }
}
export.xhtml (content of h:body)
<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>

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, the xls as a binary Excel file and the xlsx as an OOXML workbook. A button without ajax="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, and exportValue="#{book.price} EUR" replaced the cell’s content ("9.90 EUR"). exportFunction and exportHeaderValue and exportFooterValue are the other per-column hooks.

  • The XML export named its root after the table id and each row after the var (<books><book><title>...); exportTag and exportRowTag on the table and exportTag on 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.

  • options takes an ExcelOptions, PDFOptions or CSVOptions, preProcessor and postProcessor get the document (a POI Workbook for the Excel types: the postProcessXls method above cast it to org.apache.poi.ss.usermodel.Workbook and widened a column in the xlsx file; the OpenPDF document object for PDF (documentation); a StringBuilder for csvmemory).

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):

web/SafeCsvExporter.java
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;
    }
}
web/ExporterRegistration.java
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

p:dataTable

page, sort, filter, rowSelect, rowUnselect, rowSelectCheckbox, rowUnselectCheckbox, rowSelectRadio, rowDblselect, toggleSelect, rowToggle, rowEditInit, rowEdit, rowEditCancel, cellEditInit, cellEdit, cellEditCancel, rowReorder, colReorder, colResize, contextMenu, liveScroll, virtualScroll, tap, taphold

PageEvent, SortEvent, FilterEvent (package org.primefaces.event.data), SelectEvent, UnselectEvent, ToggleEvent, RowEditEvent, CellEditEvent, ReorderEvent, ColumnResizeEvent

p:treeTable

select, unselect, dblselect, expand, collapse, sort, filter, page, rowEditInit, rowEdit, rowEditCancel, cellEditInit, cellEdit, cellEditCancel, colResize, contextMenu

NodeSelectEvent, NodeUnselectEvent, NodeExpandEvent, NodeCollapseEvent

p:tree

select, unselect, expand, collapse, filter, dragdrop, contextMenu

the same, and TreeDragDropEvent

p:schedule

dateSelect, dateDblSelect, eventSelect, eventDblSelect, eventMove, eventResize, viewChange, rangeSelect

SelectEvent, ScheduleEntryMoveEvent, ScheduleEntryResizeEvent, ScheduleRangeEvent (package org.primefaces.event.schedule), DateViewChangeEvent

p:dataView, p:dataList

page (and tap, taphold on the list)

PageEvent

p:dataScroller

load

none

p:columnToggler

toggle, close

ColumnToggleEvent

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

form:books:j_idt5, j_idt12

form:books:j_id_9, j_id_a

Order of the entries in the SortEvent and FilterEvent maps

not the column order (j_idt5, j_idt12, j_idt6, j_idt7, j_idt15 after a click on the title)

the order of the columns

Whitespace in generated markup of h:panelGrid content (the expansion panel text)

a space between label and value

none

Sorting, filtering, paging, selection, editing, expansion, lazy count/load calls, trees, schedule, JPA queries and every export

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

DataTable#sortField, #sortOrder, #sortFunction, #nullSortOrder, #caseSensitiveSort and #sortMeta removed in favour of the column attributes field, sortOrder, sortPriority, sortFunction and DataTable#sortBy (a SortMeta or a collection); filterBy likewise with FilterMeta; LazyDataModel#load takes the maps of SortMeta and FilterMeta; the lazy mode is detected from the LazyDataModel; SortEvent and FilterEvent no longer carry the sorted or filtered list; p:calendar, p:dataGrid and p:dataList deprecated in favour of p:datePicker and p:dataView; FullCalendar 5 in p:schedule; Grid CSS deprecated for PrimeFlex.

11.0.0

LazyDataModel#count is mandatory; rowKey required for selection on a non-lazy table and for row expansion; tree models are generic (TreeNode<T>); set clientTimeZone on the schedule.

12.0.0

Row select mode checkbox became none; the filter match mode range was replaced by between; schedule events moved to org.primefaces.event.schedule; tree state classes moved from the label to the content.

13.0.0

The exporter API was refactored and custom exporters are registered with DataExporters.register; the selection mode is deduced from the selection binding; DataExporter#exporter deprecated; tabular p:dataGrid replaced by the responsive layout.

14.0.0

JpaLazyDataModel became JPALazyDataModel with a builder; LazyDataModel#getRowData(rowIndex, ...) became loadOne(); sortFunction takes a SortMeta as third parameter; the selection attributes were renamed (rowSelectMode to selectionRowMode, disabledSelection to selectionDisabled, disabledTextSelection to selectionTextDisabled, the column’s selectionMode to selectionBox); cellNavigation (keyboard navigation) is on by default; the toggle event of the column toggler is a ColumnToggleEvent.

15.0.0

UIData renamed PrimeUIData; FilterMeta has normalize for accents; CheckboxTreeNode extends DefaultTreeNode and TreeNode#getChildren returns TreeNodeChildren; extra themes moved out of the jar (only Saga stays), which the themes section covers.

16.0.0

javax.* support removed (Jakarta Faces 4.0 or later); widgets are JavaScript classes; TreeNodeList removed; the CSS class ui-filter-togglable became ui-filter-toggleable.

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.

Source: 10.0.0, 11.0.0, 12.0.0, 13.0.0, 14.0.0, 15.0.0 and 16.0.0 migration guides

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 selectionMode attribute 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 the selection property, the column attribute is selectionBox (14.0.0), the attributes were renamed, and a non-lazy table needs a rowKey (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 the setRowCount call is no longer needed (the method still exists). The model implements count and a load that receives maps of SortMeta and FilterMeta; lazy="true" is detected. JPALazyDataModel and DefaultLazyDataModel replace the hand-written Criteria API models the book recommends.

  • Sorting and filtering. sortBy="#{car.name}" with sortField or sortOrder on the table, and the SortOrder argument, were replaced in 10.0.0 by field, sortOrder and sortPriority on the column and by SortMeta; sort functions take three arguments since 14.0.0.

  • Schedule. The book’s LazyScheduleModel.loadEvents(Date, Date) and new DefaultScheduleEvent(title, Date, Date) use java.util.Date. In 16.0.0 the method takes LocalDateTime and events are built with DefaultScheduleEvent.builder() (both checked in the jar); the underlying FullCalendar is version 5 since 10.0.0, so the CSS and extender options 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 the exporter attribute 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), and p:calendar to p: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 but TreeNode<T> is generic (11.0.0), and the checkbox selection uses CheckboxTreeNode.

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 @PostConstruct or use a lazy model.

  • Immutable lists. List.of and Stream.toList() fail on the first sort with UnsupportedOperationException. Copy into an ArrayList.

  • Two tables on one list. They share the in-place sort.

  • field also enables filtering. Use filterable="false" where a filter box makes no sense.

  • A missing rowKey. HTTP 500 on the first request of a table with selection or rowExpansion. A rowKey that contains a comma breaks the selection.

  • Wrong event name for the selection column. rowSelect is for row clicks, rowSelectCheckbox for checkboxes.

  • A bare sortBy on the table. It did not sort the first render; use the column attributes or a List<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. Keep count and load cheap 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.

  • tree selection null. The checkbox tree writes null to its selection property; accept it.

  • A lazy tree without dynamic="true". It renders every node.

  • A schedule updated with update=. Use PF('widget').update() after changing the model, and set clientTimeZone.

  • Cell editing skips validation. The documentation says required and Bean Validation do not apply in cell mode.

  • Keeping a large model in a long scope. A @ViewScoped or 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; liveScroll with scrollRows loaded 6, 12, 18 and 24 rows on scrolling.