DEV Community

カイノヴァイイ
カイノヴァイイ

Posted on

Reactive UI in Java Without JavaScript — How I Built Livewire for the JVM

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";
    }
}
Enter fullscreen mode Exit fullscreen mode
<div live:id="{{ _id }}">
    <h2>Count: {{ count }}</h2>
    <button live:click="increment">+</button>
    <button live:click="decrement">-</button>
    <div live:loading>Updating...</div>
</div>
Enter fullscreen mode Exit fullscreen mode

Drop it in your page with one line:

{% component 'Counter' %}
Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Creates a new instance of the component class
  2. Injects @Inject dependencies (repositories, services) via the DI container
  3. Injects @Prop values if passed from the template (e.g. {% component 'UserCard' with { userId: user.id } %})
  4. Calls onMount() — your initialization hook, runs once
  5. Caches the instance in a per-session Caffeine cache (keyed by sessionId:componentId, expires after 1 hour)
  6. 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;
Enter fullscreen mode Exit fullscreen mode

5. Handling subsequent actions

When the user interacts with an already-mounted component, the server:

  1. Retrieves the cached component instance from the session cache
  2. Hydrates the @State fields from the client-sent state (skipping @ServerOnly fields)
  3. Re-injects the HTTP request and @Inject dependencies
  4. Executes the @Action method
  5. Calls onUpdate() — your post-action hook, runs after every action
  6. Captures the new state snapshot
  7. 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"));
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 } %}
Enter fullscreen mode Exit fullscreen mode

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.

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)