Build reactive, real-time interfaces without writing JavaScript. Server-side components with automatic state synchronization.
@LiveComponentImpl
public class Counter extends LiveComponent {
@State
private int count = 0;
@Action
public void increment() { count++; }
@Action
public void decrement() { count--; }
public int getCount() { return count; }
@Override
public String template() {
return "components/counter.html";
}
}
<div live:id="{{ _id }}">
<h2>Count: {{ count }}</h2>
<button live:click="increment">+ Increment</button>
<button live:click="decrement">- Decrement</button>
<div live:loading>Updating...</div>
</div>
{{ component('Counter') | raw }}
{{ component('UserCard', { userId: user.id }) | raw }}
No setup required. The LiveComponents runtime script is injected automatically before </body>
and SessionMiddleware is applied globally to all routes.
Fields annotated with @Prop are injected before onMount() and never exposed in client state.
@LiveComponentImpl
public class UserCard extends LiveComponent {
@Prop
private int userId;
@State
private User user;
@Override
public void onMount() {
user = User.findById(userId);
}
@Override
public String template() {
return "components/user-card.html";
}
}
{{ component('UserCard', { userId: user.id }) | raw }}
@Override
public void onMount() {
// Called once after props injection, before first render
// Use to load initial data
}
@Override
public void onUpdate() {
// Called after every action execution, before re-render
// Use to recompute derived state
}
session("key") // Read from session
session("key", value) // Write to session
param("name") // Query/form parameter
param("name", "default") // With fallback
routeParam("id") // Route parameter (:id)
Only methods annotated with @Action can be invoked from the client. Any unlisted method throws a ComponentException immediately.
@Action
public void save() {
// Called via live:click="save"
}
@Action
public void delete(int id) {
// Called via live:click="delete({{ id }})"
}
Actions can return a ComponentResponse to trigger a client-side navigation or dispatch a custom event after re-render.
@Action
public ComponentResponse save() {
// Persist data...
return redirect("/dashboard");
}
@Action
public ComponentResponse delete() {
return emit("toast:show", Map.of("message", "Deleted!"));
}
// Listen to the event on the client
window.addEventListener("toast:show", (e) => {
console.log(e.detail.message); // "Deleted!"
});
@LiveComponentImpl
public class SearchFilter extends LiveComponent {
@State
private String search = "";
private final List<String> items = List.of(
"React", "Vue", "Angular", "Svelte", "Spring Boot"
);
public List<String> getFilteredItems() {
if (search == null || search.isEmpty()) return items;
return items.stream()
.filter(i -> i.toLowerCase().contains(search.toLowerCase()))
.collect(Collectors.toList());
}
public String getSearch() { return search; }
@Override
public String template() { return "components/search.html"; }
}
<div live:id="{{ _id }}">
<input
live:model="search"
live:debounce="300"
type="text"
placeholder="Search..."
value="{{ search }}"
>
<div>{{ filteredItems.size() }} results</div>
{% for item in filteredItems %}
<div>{{ item }}</div>
{% endfor %}
</div>
@LiveComponentImpl
public class Dashboard extends LiveComponent {
@State
private int activeUsers = 0;
@Override
public void onMount() {
activeUsers = userService.getActiveCount();
}
@Action
public void refreshStats() {
activeUsers = userService.getActiveCount();
}
public int getActiveUsers() { return activeUsers; }
@Override
public String template() { return "components/dashboard.html"; }
}
<div
live:id="{{ _id }}"
live:poll.5s="refreshStats"
>
<h2>Active Users: {{ activeUsers }}</h2>
<div live:loading.class="opacity-100" class="opacity-0">Updating...</div>
</div>
Mount a component after first paint to avoid blocking page render. The innerHTML is shown as a skeleton during fetch.
<!-- Plain -->
<div live:lazy="PlayerSearch"></div>
<!-- With props -->
<div live:lazy="UserCard" live:props='{"userId": 42}'></div>
<!-- With skeleton -->
<div live:lazy="PlayerSearch">
<div class="animate-pulse h-8 bg-zinc-800 rounded"></div>
</div>
| Directive | Description |
|---|---|
| live:click="action" | Calls an @Action on click |
| live:click="action({{ id }})" | Calls an action with a parameter |
| live:model="field" | Two-way binding — syncs input value to state on input |
| live:debounce="300" | Debounces live:model updates (ms) |
| live:blur | Update model on blur instead of input |
| live:enter | Update model on Enter key |
| live:submit="action" | Intercepts form submission and calls action |
| live:poll="5s" | Auto-refresh every N seconds/minutes |
| live:poll.5s="action" | Auto-refresh and call a specific action |
| live:init="action" | Calls action on component mount |
| live:confirm="message" | Shows a confirmation dialog before calling action |
| live:loading | Shows element while action is in progress |
| live:loading.class="cls" | Adds class during loading |
| live:loading.add="cls" | Adds class during loading, removes after |
| live:lazy="Component" | Lazy mounts a component after first paint |
| live:props='{"key": val}' | Props passed to a lazy-mounted component |
{{ livecomponents_scripts | raw }} from all templates — script is now injected automatically before </body>@Before(SessionMiddleware.class) from controller methods — middleware is now global@Action is now required on all client-callable methods — add it to prev(), next(), and any action method or they will throw ComponentException at runtimelive:lazy on inputs (update on Enter key) has been renamed to live:enter