I run a Minecraft server called SyperCraft. The website has player directories, faction pages, live search with filters — the kind of stuff you'd normally reach for React or Vue to build.
But the backend is Java. And I really, really didn't want to maintain a separate JavaScript frontend just to make a search bar filter results in real-time.
So I built LiveComponents — a server-side reactive UI system for Java, inspired by Laravel Livewire. No JavaScript to write. No API endpoints to wire up. Just a Java class and an HTML template.
It's part of Obsidian, the framework I introduced in my last post.
The idea in 30 seconds
You write a Java class with @State fields and @Action methods. You write an HTML template that references them. Obsidian handles the rest — when the user clicks a button or types in an input, the component calls the server, re-renders, and patches the DOM. No JS to write.
@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; }
public String template() {
return "components/counter.html";
}
}
<div live:id="{{ _id }}">
<h2>Count: {{ count }}</h2>
<button live:click="increment">+</button>
<button live:click="decrement">-</button>
<div live:loading>Updating...</div>
</div>
Drop it in your page with one line:
{% component 'Counter' %}
That's the counter example. But counters are boring. Let me show you what this looks like on a real project.
A real example: live player search
On sypercraft.fr/joueurs, players can search through the server's member list. They type a name, pick a rank filter, toggle online/offline status — and the results update instantly. No page reload.
Here's what the component looks like (simplified):
@LiveComponentImpl
public class PlayerSearch extends LiveComponent {
@State
private String search = "";
@State
private String rank = "all";
@State
private String status = "all";
@Inject
private PlayerRepository playerRepo;
@Action
public void selectRank(String rank) {
this.rank = rank;
}
@Action
public void selectStatus(String status) {
this.status = status;
}
public List<Player> getFilteredPlayers() {
return playerRepo.search(search, rank, status);
}
@Override
public String template() {
return "components/player-search.html";
}
}
And in the template:
<div live:id="{{ _id }}">
<input
live:model="search"
live:debounce="300"
type="text"
placeholder="Search a player..."
value="{{ search }}"
>
<div class="filters">
<button live:click="selectRank('all')">All ranks</button>
<button live:click="selectRank('Moderator')">Moderator</button>
<button live:click="selectRank('Admin')">Admin</button>
<!-- ... -->
</div>
<div class="results">
{% for player in filteredPlayers %}
<div class="player-card">
<img src="https://mc-heads.net/avatar/{{ player.name }}">
<span>{{ player.name }}</span>
</div>
{% endfor %}
</div>
<div live:loading>Searching...</div>
</div>
That's it. The search input uses live:model with a 300ms debounce — every keystroke triggers a server round-trip, but only after the user stops typing. The rank and status buttons use live:click to call actions. The whole component re-renders server-side and the DOM gets patched.
No fetch calls. No useState. No useEffect. Just Java and HTML.
How it works under the hood
LiveComponents aren't magic. The system is simple, and that's the point.
1. The client runtime
When the page loads, a small JavaScript runtime is automatically injected before </body> by the framework's template engine — you don't add any script tag yourself. It scans the DOM for elements with live:id attributes, registers each component, and attaches event listeners: click handlers for live:click, input listeners with debouncing for live:model, polling timers for live:poll, form handlers for live:submit.
2. User interaction → POST to server
When the user types or clicks, the runtime sends a POST request to /obsidian/components:
{
"componentId": "player-search-a1b2c3",
"action": "selectRank",
"state": { "search": "Aria", "rank": "all" },
"params": ["Moderator"]
}
The state is captured from the current DOM inputs. The action name and params are parsed from the attribute — even method-style calls like selectRank('Moderator') work out of the box. The endpoint is CSRF-protected and rate-limited.
3. Security layer
This is the part I care about the most. Server-side rendering means the server is in control, but you still need to be careful about what the client can do.
@Action is mandatory. Only methods explicitly annotated with @Action are callable from the client. If a method doesn't have the annotation, the server rejects the call. This closes the implicit security gap where any public method could be invoked by crafting a request.
@ServerOnly protects sensitive state. Fields annotated with @State @ServerOnly are excluded from client-side hydration — the hydrate() method skips them entirely. The client never sees them, can never override them.
@State
@ServerOnly
private Long userId; // client cannot forge this
Lazy mount is restricted. The mount endpoint checks componentManager.isRegistered() before instantiating anything — you can't trick the server into mounting arbitrary classes.
4. Mounting a component
When a component is first rendered (via {% component 'PlayerSearch' %} in a template), the framework:
- Creates a new instance of the component class
- Injects
@Injectdependencies (repositories, services) via the DI container - Injects
@Propvalues if passed from the template (e.g.{% component 'UserCard' with { userId: user.id } %}) - Calls
onMount()— your initialization hook, runs once - Caches the instance in a per-session Caffeine cache (keyed by
sessionId:componentId, expires after 1 hour) - Captures the state snapshot and renders the Pebble template
Props use @Prop instead of @State — they're injected at mount time only and never part of the client state:
@Prop
private int userId;
5. Handling subsequent actions
When the user interacts with an already-mounted component, the server:
- Retrieves the cached component instance from the session cache
- Hydrates the
@Statefields from the client-sent state (skipping@ServerOnlyfields) - Re-injects the HTTP request and
@Injectdependencies - Executes the
@Actionmethod - Calls
onUpdate()— your post-action hook, runs after every action - Captures the new state snapshot
- Re-renders the Pebble template and returns the HTML
The runtime swaps the component's DOM node. One detail I'm proud of: it preserves focus and cursor position after re-render. If you're typing in a search field, the cursor stays exactly where it was. Annoying to get right, but it matters.
6. Action responses
Actions don't just update state. They can return a ComponentResponse to trigger side effects:
@Action
public ComponentResponse save() {
// persist, then redirect
return redirect("/dashboard");
}
@Action
public ComponentResponse delete() {
// remove, then notify the client
return emit("toast:show", Map.of("message", "Deleted"));
}
Redirects navigate the browser. Events are dispatched as CustomEvent on the document — you can listen for them in other components or in a small script if needed.
7. Loading states are automatic
Anything with a live:loading attribute is shown during the round-trip and hidden after. Buttons with live:click get disabled automatically to prevent double-clicks. No extra code.
The directive cheat sheet
Here's what you can do without writing a single line of JavaScript:
| Directive | What it does |
|---|---|
live:click="method" |
Calls a server action on click |
live:click="method('arg')" |
Same, with parameters |
live:model="field" |
Two-way binding on inputs |
live:debounce="300" |
Debounces model updates (ms) |
live:submit="method" |
Handles form submission |
live:loading |
Shows element during server call |
live:loading.add="opacity-50" |
Adds CSS class during loading |
live:confirm="Sure?" |
Shows confirmation before action |
live:poll="5s" |
Auto-refreshes every 5 seconds |
live:init="method" |
Calls action on component mount |
live:lazy="ComponentName" |
Lazy-loads component after page render |
live:enter |
Triggers model update only on Enter |
live:blur |
Triggers model update only on blur |
The live:lazy directive is useful for heavy components. Instead of blocking the page render, the component loads asynchronously after mount:
<div live:lazy="PlayerSearch"></div>
You can even pass props — to lazy-loaded components via live:props, or to regular components via the {% component %} tag:
{% component 'UserCard' with { userId: user.id } %}
What this is NOT
Let me be clear about the tradeoffs:
- It's not a replacement for React/Vue. If you need complex client-side state, offline support, or 60fps animations — use a JS framework.
- It's not WebSocket-based. Every interaction is a plain HTTP POST. Simple, debuggable, no connection to maintain.
- It adds latency. Every action is a server round-trip. For most CRUD apps, dashboards, and admin panels, you won't notice. For a real-time game UI, you will.
- It's server-rendered. Your server does more work. For a Minecraft server website with moderate traffic, that's fine. For millions of concurrent users, think twice.
The sweet spot? Server-rendered apps where you need interactivity but don't want the complexity of a JS build pipeline. Internal tools, dashboards, CMS pages, game server websites. The stuff that makes up 80% of web apps but gets over-engineered with SPAs (Single Page Applications — the React/Vue/Angular model where everything runs client-side).
Try it
LiveComponents are part of Obsidian, an open-source Java web framework built on Spark Java.
- Live demo: obsidian-java.com/demo/live-components — try it right now, no setup needed
- Repo: github.com/obsidian-framework
- LiveComponents examples: github.com/obsidian-framework/livecomponents-examples
- Docs: obsidian-java.com/docs
If you've ever thought "I just want a reactive search bar without setting up a whole Node toolchain" — this might be your thing.
Star the repo if you want to follow along. Open an issue if something breaks.
Top comments (0)