Iteration & Conditionals
|
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 Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08, and
every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, H2 in memory, JDK 26 with --release 21,
MockMvc and the plain SpringTemplateEngine). th:each, th:if, th:unless, th:switch and th:case belong to the Standard Dialect, so everything
here applies to Thymeleaf standalone, with Spring MVC and with Spring WebFlux alike; the examples use the Spring dialect (SpEL), and
the only differences from the standalone engine (OGNL) are the expression language and the Spring-only features named where they appear. The
running scenario is the Bookshelf introduced on the Getting Started page: this page builds its
catalogue (a table of titles with author, year, copies, status and tags) step by step.
The model used by the examples
The examples render a small in-memory catalogue so that every output can be reproduced without a database. A Title is a plain class with getters
(the Bookshelf’s persistent entities would work the same way), and CatalogueData builds four titles and three genres, one of which is empty.
public class Title {
public enum Status { AVAILABLE, ON_LOAN, LOST }
private final String name;
private final String author;
private final int year;
private final int copies;
private final Status status;
private final List<String> tags;
public Title(String name, String author, int year, int copies, Status status, List<String> tags) {
this.name = name; this.author = author; this.year = year;
this.copies = copies; this.status = status; this.tags = tags;
}
public String getName() { return name; }
public String getAuthor() { return author; }
public int getYear() { return year; }
public int getCopies() { return copies; }
public Status getStatus() { return status; }
public List<String> getTags() { return tags; }
}
public class Genre {
private final String name;
private final List<Title> titles;
public Genre(String name, List<Title> titles) { this.name = name; this.titles = titles; }
public String getName() { return name; }
public List<Title> getTitles() { return titles; }
}
public final class CatalogueData {
public static List<Title> titles() {
return List.of(
new Title("Dune", "Frank Herbert", 1965, 3, Status.AVAILABLE, List.of("classic", "space")),
new Title("Neuromancer", "William Gibson", 1984, 0, Status.ON_LOAN, List.of("cyberpunk")),
new Title("Solaris", "Stanislaw Lem", 1961, 1, Status.AVAILABLE, List.of()),
new Title("Hyperion", "Dan Simmons", 1989, 0, Status.LOST, List.of("space")));
}
public static List<Genre> genres() {
List<Title> all = titles();
return List.of(new Genre("Space opera", List.of(all.get(0), all.get(3))),
new Genre("Cyberpunk", List.of(all.get(1))),
new Genre("Hard SF", List.of()));
}
}
Iteration with th:each
A page that lists things needs a way to say "repeat this piece of markup once per element". Thymeleaf keeps the template natural by letting you write
one exemplar row, as a designer would mock it up, and attach th:each to it. The attribute value has the shape iterVar : ${iteratedExpression}, read as
"for each element in the result of the iterated expression, repeat this element, binding the current element to the iteration variable". The
iteration variable is scoped to the element that carries th:each and everything inside it, so the <td> cells see it but a sibling element does not.
The attribute is processed by the Standard Dialect’s th:each attribute processor at template processing time (not at parse time). It repeats the
host element together with its body, so on a table you put it on the <tr>, on a list on the <li>. th:each has a higher precedence than th:if,
which matters when both are on one element (see Attribute precedence below). It is not one of the attributes evaluated in the restricted
expression mode: the iterated expression runs in the general mode, where only the general restrictions apply (static access to most JDK, Spring and
Thymeleaf infrastructure classes is refused). The template stays valid HTML and viewable in a browser, because the other rows are simply absent from the prototype.
Controller side, the Bookshelf controller only puts the data in the model; the template does the repetition.
@Controller
public class CatalogueController {
@GetMapping("/catalogue")
public String catalogue(Model model, @RequestParam(defaultValue = "false") boolean empty) {
model.addAttribute("titles", empty ? List.of() : CatalogueData.titles());
model.addAttribute("genres", CatalogueData.genres());
return "iter/catalogue";
}
}
A first, minimal template repeats a list row:
<table>
<tr th:each="title : ${titles}">
<td th:text="${title.name}">Dune</td>
<td th:text="${title.author}">Frank Herbert</td>
</tr>
</table>
Source: Using Thymeleaf — Using th:each
Rendered (one <tr> per title, and the prototype row has disappeared into the repetition):
<table>
<tr><td>Dune</td><td>Frank Herbert</td></tr>
<tr><td>Neuromancer</td><td>William Gibson</td></tr>
<tr><td>Solaris</td><td>Stanislaw Lem</td></tr>
<tr><td>Hyperion</td><td>Dan Simmons</td></tr>
</table>
If the iterated expression evaluates to null, the loop simply runs zero times; no exception is raised and the element is not output. A different
th:each on a <th:block> repeats several sibling elements without adding a wrapper (the synthetic block element is described on the local variables page of
this section), for instance a definition list:
<dl>
<th:block th:each="title : ${titles}">
<dt th:text="${title.name}">Dune</dt>
<dd th:text="${title.author}">Frank Herbert</dd>
</th:block>
</dl>
Rendered: <dt>Dune</dt><dd>Frank Herbert</dd><dt>Neuromancer</dt><dd>William Gibson</dd> and so on, with no th:block left in the output.
What can be iterated
th:each is not limited to java.util.List. The tutorial lists the supported kinds, and the table adds what the iteration variable holds and what
was observed on 3.1.5 when the status variable is used with each kind (see The iteration status variable).
| The iterated expression is | The iteration variable is | Notes |
|---|---|---|
Any |
The element |
A |
A |
A |
Use |
An array (including primitive arrays) |
The element |
The size is the array length. |
A |
The element |
Values are used as the iterator returns them, without caching them in memory. The size is unknown (see the pitfalls below). |
A |
The element |
Same streaming behaviour as an iterator. |
A |
The element |
A stream can be consumed only once, so a second |
Any other object, or a single value |
The object itself |
It is treated as a one-element list, so |
|
(none) |
Zero iterations. |
The next template iterates one of each kind. The model variables are set as follows (a standalone-style Context, the same names a controller would put in a Model):
Context ctx = new Context();
ctx.setVariable("list", List.of("Dune", "Solaris"));
Map<String, Integer> copies = new LinkedHashMap<>();
copies.put("Dune", 3);
copies.put("Solaris", 1);
ctx.setVariable("map", copies);
ctx.setVariable("array", new String[] {"x", "y"});
ctx.setVariable("iterator", List.of("i1", "i2", "i3").iterator());
ctx.setVariable("enumeration", Collections.enumeration(List.of("e1", "e2")));
ctx.setVariable("stream", Stream.of("s1", "s2", "s3"));
ctx.setVariable("single", "just one");
ctx.setVariable("nothing", null);
<li th:each="t : ${list}" th:text="${t}">t</li>
<li th:each="e : ${map}" th:text="${e.key} + ' = ' + ${e.value}">e</li>
<li th:each="t : ${array}" th:text="${t}">t</li>
<li th:each="t : ${iterator}" th:text="${t}">t</li>
<li th:each="t : ${enumeration}" th:text="${t}">t</li>
<li th:each="t : ${stream}" th:text="${t}">t</li>
<li th:each="t : ${single}" th:text="${t}">t</li>
<li th:each="t : ${nothing}" th:text="${t}">t</li>
<li th:each="n : ${#numbers.sequence(1,3)}" th:text="${n}">n</li>
Each line rendered on its own (the null line renders nothing):
| Iterated expression | Rendered output |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(nothing) |
|
|
A numeric loop (a rating of stars, page buttons) has no dedicated attribute: iterate the list returned by the #numbers.sequence(from, to) utility
object, which is described with the other expression utility objects in this section.
Two cautions come from the streaming kinds. A Stream variable that is read by two th:each attributes fails on the second one, because the stream was
already consumed; the failure reaches you as a TemplateInputException whose root cause is java.lang.IllegalStateException: stream has already been
operated upon or closed. Put a Stream in the model only when exactly one loop reads it, or collect it to a List in the controller. Because an
Iterator, Enumeration or Stream can be read only once and has no size, a List is the safer default for a model attribute.
The iteration status variable
Many layouts need more than the element: a row number, a separator between items but not after the last, a zebra stripe. th:each offers a status
variable for this. It is declared by adding a second name after the iteration variable, separated by a comma: th:each="title, stat : ${titles}".
The status object has these properties:
| Property | Meaning |
|---|---|
|
The current iteration index, starting with 0. |
|
The current iteration index, starting with 1. |
|
The total number of elements of the iterated variable, when it is known. |
|
The iteration variable of the current iteration (the current element). |
|
Whether the current iteration is even or odd. They follow |
|
True on the first iteration. |
|
True on the last iteration. It is computed from |
If you do not name a status variable, Thymeleaf creates one for you named after the iteration variable with the suffix Stat, so
th:each="title : ${titles}" makes titleStat available. The status variable has the same scope as the iteration variable. The following template
prints every property and the implicit variable for the Bookshelf titles:
<tr th:each="t, s : ${titles}">
<td th:text="${s.index} + ',' + ${s.count} + ',' + ${s.size} + ',' + ${s.current.name} + ',' + ${s.even} + ',' + ${s.odd} + ',' + ${s.first} + ',' + ${s.last}">x</td>
</tr>
<tr th:each="t : ${titles}">
<td th:text="${tStat.count} + '/' + ${tStat.size}">x</td>
</tr>
Rendered (index, count, size, current name, even, odd, first, last):
<tr><td>0,1,4,Dune,false,true,true,false</td></tr>
<tr><td>1,2,4,Neuromancer,true,false,false,false</td></tr>
<tr><td>2,3,4,Solaris,false,true,false,false</td></tr>
<tr><td>3,4,4,Hyperion,true,false,false,true</td></tr>
<tr><td>1/4</td></tr>
<tr><td>2/4</td></tr>
<tr><td>3/4</td></tr>
<tr><td>4/4</td></tr>
The most useful last pattern is a separator that is not printed after the final element. The tags of a title are joined with commas without a trailing
comma by testing last of an inner loop:
<span th:each="tag, tagStat : ${title.tags}"
th:text="${tag} + (${tagStat.last} ? '' : ', ')">tag</span>
For the tags of Dune this renders <span>classic, </span><span>space</span>.
When size and last are not available
size and last need the size of the iterated object. Thymeleaf knows it for a Collection, a Map, an array and a single object. For the lazily
traversed kinds the behaviour observed on 3.1.5 is the following, and it is worth knowing before you use last on a stream of data:
| Iterated kind | size |
last |
|---|---|---|
|
The real size |
Correct |
A single object |
1 |
True |
|
|
Throws: SpEL reports |
|
1 (not the real size) |
True on the first element and false afterwards (not reliable) |
A custom |
|
Throws, as for an iterator |
The status object also has a hasSize() method that is false when the size is unknown, so a template can guard last with it, but the simplest rule is
to collect an Iterator, Enumeration or Stream into a List in the controller whenever the page needs size or last. index, count, first,
even, odd and current work for every kind.
Striped tables
A zebra stripe is the classic use of the status variable: give odd rows a CSS class. There are two idiomatic forms. The first sets the whole class
attribute only for odd rows (an expression that yields null removes the attribute), which is the form in the tutorial:
<tr th:each="t, s : ${titles}" th:class="${s.odd} ? 'odd'">
<td th:text="${t.name}">Dune</td>
</tr>
Rendered: <tr class="odd"><td>Dune</td></tr><tr><td>Neuromancer</td></tr><tr class="odd"><td>Solaris</td></tr><tr><td>Hyperion</td></tr>, so
even rows carry no class attribute at all. The second keeps the static classes of the prototype and appends one of two classes, which is what you
want when the row already has a class such as row:
<tr class="row" th:each="t, s : ${titles}" th:classappend="${s.even} ? 'even' : 'odd'">
<td th:text="${t.name}">Dune</td>
</tr>
Rendered: <tr class="row odd"> for Dune and Solaris and <tr class="row even"> for Neuromancer and Hyperion. The th:class, th:classappend and
related attributes are described on the attribute-setting page of this section. When the stripe is purely decorative, CSS does it with no template
logic (tbody tr:nth-child(odd)), which is the better choice for a static table. Use the status variable when the class must be computed, or
when the same list is rendered in a context without your stylesheet.
Lazy retrieval with LazyContextVariable
A page often reads a large collection only under a condition: a "show details" flag, an authorisation check, a collapsed panel. Loading the collection
in the controller costs a query even when the page ends up not using it. Thymeleaf offers lazily resolved context variables for this. A variable that
implements org.thymeleaf.context.ILazyContextVariable, usually by extending the default implementation org.thymeleaf.context.LazyContextVariable<T> and
implementing loadValue(), is resolved only at the moment an expression reads it. Templates use it exactly like an ordinary variable and need no
knowledge of its laziness.
In Spring MVC the lazy variable is simply a model attribute:
@Controller
public class LazyController {
private final AtomicInteger loads = new AtomicInteger();
@GetMapping("/lazy")
public String lazy(Model model, @RequestParam(defaultValue = "false") boolean showTitles) {
model.addAttribute("showTitles", showTitles);
// loadValue() runs only if a template expression actually reads ${titles}
model.addAttribute("titles", new LazyContextVariable<List<Title>>() {
@Override
protected List<Title> loadValue() {
loads.incrementAndGet();
return CatalogueData.titles();
}
});
return "iter/lazy";
}
int loads() {
return loads.get();
}
}
<ul th:if="${showTitles}">
<li th:each="title : ${titles}" th:text="${title.name}">Dune</li>
</ul>
With /lazy (flag false) the list is not rendered and loadValue() is never called; with /lazy?showTitles=true the <ul> contains the four titles and
loadValue() ran once. A test can assert both facts through the counter:
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class CatalogueMvcTest {
@Autowired MockMvc mvc;
@Autowired LazyController lazyController;
@Test
void lazyVariableIsLoadedOnlyWhenRead() throws Exception {
int before = lazyController.loads();
mvc.perform(get("/lazy")).andExpect(status().isOk());
assertThat(lazyController.loads()).isEqualTo(before);
mvc.perform(get("/lazy").param("showTitles", "true")).andExpect(status().isOk());
assertThat(lazyController.loads()).isEqualTo(before + 1);
}
}
Two properties of the implementation are worth knowing. The value is memoized per variable instance: when the same lazy variable is read by two
expressions in one rendering (two copies of the list above in a standalone test) loadValue() still ran once. And the laziness only covers the
loading; it does not make iteration streaming. Once the variable is read, the whole returned value exists in memory. Keep loadValue() free of side effects
and do not close over a request-scoped resource that may be gone by the time the view renders (with Spring Boot, remember that
spring.jpa.open-in-view decides whether lazy JPA associations can still be loaded while the view renders).
Conditionals with th:if and th:unless
th:if renders the element, with its body, only when its expression is true; th:unless is the inverse and renders it only when the expression is
false. Both are processed at template processing time, and when the condition is not met the element is simply not output (it is not hidden with CSS).
They evaluate their value in the general expression mode, which is why you can write ordinary boolean expressions with and, or, not, the
comparison operators and the utility objects. Their value does not have to be a boolean.
The truthiness rules
th:if evaluates the expression as true following these rules, taken from the tutorial and verified on 3.1.5:
-
If the value is
null, it is false. -
If the value is a boolean, it is that boolean.
-
If the value is a number, it is true when non-zero.
-
If the value is a character, it is true when non-zero.
-
If the value is a
String, it is true unless it is"false","off"or"no"(the comparison ignores case:"FALSE"and"No"are false too). -
If the value is none of boolean, number, character or string, it is true (because it is not
null).
The consequences are easy to get wrong, so the table lists what a probe template (<p th:if="${v}">T</p><p th:unless="${v}">F</p>) printed for common values:
Value of ${v} |
Result | Remark |
|---|---|---|
|
T / F |
A boolean is itself. |
|
F |
A missing model attribute or a null property is false. |
|
F |
Zero numbers are false; |
|
T / F |
Only the zero character is false. |
|
T |
Any string except the three words below. |
|
F |
The case-insensitive words |
|
T |
Not false. Test |
|
T |
Only the three words are false. |
An empty |
T |
A non-null object is true even when empty. Test |
|
T |
It is an object. Unwrap it in the controller instead of testing it in the template. |
Any other object |
T |
True because it is not |
Because of the empty-string and empty-collection rows, the idiomatic emptiness tests are explicit: th:if="${not #lists.isEmpty(titles)}", th:if="${!titles.isEmpty()}"
or th:unless="${titles.empty}". Both not and ! negate, and th:unless avoids the negation. A fragment of the catalogue that shows a link only
for titles that have copies:
<a th:if="${title.copies > 0}" th:href="@{/loans/new(titleName=${title.name})}">Borrow</a>
<span th:unless="${title.copies > 0}">No copies left</span>
The > in ${title.copies > 0} is fine inside an attribute value in HTML mode; gt is the alternative that never needs escaping in XML and XHTML templates.
The expression syntax page of this section describes the other Standard Expression forms that can be used as conditions.
Branching with th:switch and th:case
When one value selects among several outputs, a chain of th:if is noisy. th:switch sets the value on a container element and th:case marks
the alternatives inside it:
<td th:switch="${title.status.name()}">
<span th:case="'AVAILABLE'">Available</span>
<span th:case="'ON_LOAN'">On loan</span>
<span th:case="*">Unavailable</span>
</td>
For Dune, Neuromancer and Hyperion this renders <span>Available</span>, <span>On loan</span> and <span>Unavailable</span>. The rules, all verified:
-
As soon as one
th:caseis true, every otherth:casein the same switch is evaluated as false: the first match wins, even if a later case would match too (twoth:case="'manager'"branches print only the first). -
The default branch is
th:case="*". It matches whatever is left, so it must come last: when it was placed before a specific case, the default branch was rendered for a matching value and the specific case was not. -
th:switchandth:casecompare with the Standard Expression equality, so numbers compare as numbers (th:case="3"matches a value of 3), and anullswitch value matchesth:case="null". -
An enum switch value does not match a string literal.
th:switch="${title.status}"withth:case="'AVAILABLE'"rendered the default branch for every title. Either switch on the name (${title.status.name()}, as above) or compare against the constant itself with aT()expression on your own enum type, which worked because the type is application code (the general restrictions refuse such access to most JDK and framework classes):
<div th:switch="${title.status}">
<b th:case="${T(com.example.bookshelf.iter.Title.Status).AVAILABLE}">Available</b>
<b th:case="*">Other</b>
</div>
The T() form is a convenience for the examples. In a real application a status label is better looked up in a message bundle, as the next section shows,
and the th:switch is reserved for markup that really differs between values.
The complete catalogue: stripes, status, tags and an empty state
The full Bookshelf catalogue template puts the pieces together. It renders a table only when there are titles, stripes the rows with the status variable,
switches the status label, joins the tags with the last property of a nested loop and shows an "untagged" marker when a title has none. When the list is empty, a plain
paragraph replaces the whole table, so the page never shows a headline with empty column headers.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Catalogue</title></head>
<body>
<h1>Catalogue</h1>
<table th:unless="${#lists.isEmpty(titles)}">
<thead>
<tr><th>#</th><th>Title</th><th>Author</th><th>Year</th><th>Copies</th><th>Status</th><th>Tags</th></tr>
</thead>
<tbody>
<tr th:each="title, stat : ${titles}" th:class="${stat.odd} ? 'odd' : 'even'">
<td th:text="${stat.count}">1</td>
<td th:text="${title.name}">Dune</td>
<td th:text="${title.author}">Frank Herbert</td>
<td th:text="${title.year}">1965</td>
<td th:text="${title.copies}">3</td>
<td th:switch="${title.status.name()}">
<span th:case="'AVAILABLE'">Available</span>
<span th:case="'ON_LOAN'">On loan</span>
<span th:case="*">Unavailable</span>
</td>
<td>
<span th:each="tag, tagStat : ${title.tags}"
th:text="${tag} + (${tagStat.last} ? '' : ', ')">tag</span>
<em th:if="${#lists.isEmpty(title.tags)}">untagged</em>
</td>
</tr>
</tbody>
</table>
<p th:if="${#lists.isEmpty(titles)}">The catalogue is empty.</p>
</body>
</html>
GET /catalogue renders (white space condensed; the first and third rows are odd, the second and fourth even):
<table>
<thead>
<tr><th>#</th><th>Title</th><th>Author</th><th>Year</th><th>Copies</th><th>Status</th><th>Tags</th></tr>
</thead>
<tbody>
<tr class="odd">
<td>1</td><td>Dune</td><td>Frank Herbert</td><td>1965</td><td>3</td>
<td><span>Available</span></td>
<td><span>classic, </span><span>space</span></td>
</tr>
<tr class="even">
<td>2</td><td>Neuromancer</td><td>William Gibson</td><td>1984</td><td>0</td>
<td><span>On loan</span></td>
<td><span>cyberpunk</span></td>
</tr>
<tr class="odd">
<td>3</td><td>Solaris</td><td>Stanislaw Lem</td><td>1961</td><td>1</td>
<td><span>Available</span></td>
<td><em>untagged</em></td>
</tr>
<tr class="even">
<td>4</td><td>Hyperion</td><td>Dan Simmons</td><td>1989</td><td>0</td>
<td><span>Unavailable</span></td>
<td><span>space</span></td>
</tr>
</tbody>
</table>
GET /catalogue?empty=true renders only <h1>Catalogue</h1> followed by <p>The catalogue is empty.</p>: the th:unless removed the table and the
th:if kept the message. A test for the three behaviours uses MockMvc:
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class CatalogueMvcTest {
@Autowired MockMvc mvc;
@Test
void rowsAreStripedAndStatusIsSwitched() throws Exception {
String html = mvc.perform(get("/catalogue"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("<tr class=\"odd\">")))
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("<span>On loan</span>", "<span>Unavailable</span>", "<em>untagged</em>");
assertThat(html).containsOnlyOnce("<span>classic, </span>");
}
@Test
void emptyCatalogueShowsMessageInsteadOfTable() throws Exception {
mvc.perform(get("/catalogue").param("empty", "true"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("The catalogue is empty.")))
.andExpect(content().string(not(containsString("<table"))));
}
}
Three empty-state patterns are worth distinguishing:
-
Hide the container, show a message (above): one
th:unlesson the table and oneth:ifon the message, using the same emptiness test. Compute the test once with a local variable (th:with, described on the local variables page of this section) if it is expensive. -
Keep the table, add a row: an extra
<tr th:if="${#lists.isEmpty(titles)}"><td colspan="7">Nothing here</td></tr>inside the<tbody>. The headers stay visible, which suits filtered searches. -
Decide in the controller: return a different view name (or add a boolean) when there is nothing to show, which keeps the template free of branching but spreads presentation decisions into Java.
Nested iteration
Data that has a parent and children, such as genres that contain titles, needs one th:each inside another. Each loop has its own iteration and
status variable; use distinct names, and the inner loop can read the outer variable.
<section th:each="genre, genreStat : ${genres}">
<h2 th:text="${genre.name}">Space opera</h2>
<ul th:if="${!genre.titles.empty}">
<li th:each="title, titleStat : ${genre.titles}"
th:text="${titleStat.count} + '. ' + ${title.name}">1. Dune</li>
</ul>
<p th:if="${genre.titles.empty}">No titles</p>
</section>
Rendered (the empty genre falls through to the message):
<section><h2>Space opera</h2><ul><li>1. Dune</li><li>2. Hyperion</li></ul></section>
<section><h2>Cyberpunk</h2><ul><li>1. Neuromancer</li></ul></section>
<section><h2>Hard SF</h2><p>No titles</p></section>
Each loop numbers independently, and the two status variables can be combined: ${genreStat.index} + ':' + ${titleStat.index} yields 0:0 and 0:1 in the
first genre and 1:0 in the second. An iteration variable shadows a model or local variable of the same name for the duration of the loop and
the outer value is visible again after it: a th:with="t='outer'" paragraph containing a th:each="t : ${titles}" loop printed the title names inside the
loop and outer after it. When an inner and outer variable need to coexist, give them different names rather than relying on shadowing.
The order of evaluation inside a nested structure is outer first. The outer element’s th:each runs before the inner element’s attributes, so the
inner loop and any th:if on it see the current outer element, and a condition on the outer element can drop the inner loop altogether, as the lazy-loading example does.
Attribute precedence with th:each and th:if
When several th:* attributes sit on the same element Thymeleaf applies them in a fixed order, not the order they are written. In the tutorial’s
precedence table, fragment iteration (th:each) comes before conditional evaluation (th:if, th:unless, th:switch, th:case), so the condition is
evaluated once per iteration and can read the iteration variable. A filter on the same element works as expected:
<li th:each="title : ${titles}" th:if="${title.copies > 0}" th:text="${title.name}">Dune</li>
Rendered: <li>Dune</li><li>Solaris</li> (the two titles with copies). The complete precedence table, with every attribute, is on the local variables, precedence,
comments and blocks page of this section. Two practical choices follow. Filtering in the template is acceptable for a short list, but for a large one it
iterates every element only to discard most of them, so filter in the controller or the service (or in the query) instead. When you do want a template-side
filter without a condition per element, a SpEL collection selection is a compact alternative: th:each="title : ${titles.?[copies > 0]}" rendered the same two
titles, and a projection ${titles.![name]} iterates just the names. Collection selection and projection are Spring EL features; in a standalone engine that uses OGNL the equivalent syntax differs.
Listing data in a Spring application
The Thymeleaf + Spring tutorial builds its first screen as a listing and combines th:unless, th:each, nested loops, a status variable and the message bundle. The
same techniques apply to the Bookshelf. The wrapping th:unless="${#lists.isEmpty(...)}" is the empty-state pattern above; a nested loop reads
th:each="row, rowStat : ${sb.rows}" with ${rowStat.count} as the row number, exactly like the genre example.
Two Spring-specific details belong to that listing. First, an enum is translated through the message bundle with a literal-substitution key built from the value:
th:text="#{|status.${title.status}|}", with the keys status.AVAILABLE, status.ON_LOAN and status.LOST in messages.properties, which is the better way to label
a status than a th:switch because the texts become translatable. Second, the double-brace form ${{value}} applies the Spring conversion service to a value (dates,
numbers, your own types) at output time; it is the subject of the conversion and formatting page of this section.
status.AVAILABLE=Available
status.ON_LOAN=On loan
status.LOST=Lost
<ul>
<li th:each="title : ${titles}">
<span th:text="${title.name}">Dune</span>:
<span th:text="#{|status.${title.status}|}">Available</span>
</li>
</ul>
With a messages_es.properties containing status.AVAILABLE=Disponible, status.ON_LOAN=Prestado and status.LOST=Perdido, a request with
Accept-Language: es rendered <span>Disponible</span>, <span>Prestado</span>, <span>Disponible</span> and <span>Perdido</span> after the titles Dune,
Neuromancer, Solaris and Hyperion. (Spring Boot falls back to the JVM’s default locale when no bundle matches the requested language, so check what
spring.messages.fallback-to-system-locale does on your machine when a test unexpectedly returns another language.)
The natural template and the prototype rows
A natural template should look right when opened directly in a browser. A th:each row shows only one prototype row, which is usually enough, but a designer may
want to see a realistic table with several rows. Add extra mock rows with th:remove="all" so that the engine deletes them while the browser still shows them
(the attribute is covered with the other removal options on the fragments page of this section):
<table>
<tr th:each="t : ${titles}"><td th:text="${t.name}">Dune</td></tr>
<tr th:remove="all"><td>Mock row</td></tr>
</table>
Rendered: <table><tr><td>Dune</td></tr><tr><td>Neuromancer</td></tr><tr><td>Solaris</td></tr><tr><td>Hyperion</td></tr></table>, with the mock row gone.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
|
Only |
Test emptiness explicitly with |
The second loop over the same |
A stream can be consumed once |
Collect to a |
|
The size of an iterator is unknown, and |
Use a |
|
The size is only known for collections, maps, arrays and single objects |
Do not use |
|
An enum is not equal to a string literal in the Standard Expression equality |
Switch on |
A |
Only the first matching case is rendered |
Order the cases from specific to general, with |
The lazy list is loaded although the template seems not to use it |
An earlier expression, for instance |
Search the whole template; the first read loads it |
An outer variable has the wrong value inside a loop |
The iteration variable shadows a variable of the same name |
Use distinct names for the outer and inner variables |
Rows are numbered from 1 but |
|
Use |
Heavy filtering inside |
Every element is visited and discarded one by one |
Filter in the service or the query, or use a collection selection |