Push Notifications (polling)
Status: ✅ Implemented — @Trigger(OnLoad + OnSuccess), Hydratable, MicroFrontend
Intent
Section titled “Intent”Surface live information — pending tasks, system alerts, unread messages — to the user without requiring a persistent connection, and without blocking the rest of the UI.
Problem
Section titled “Problem”Enterprise apps often need to notify users of asynchronous events (a workflow task assigned, an import that finished, an approval waiting). WebSockets add infrastructure complexity; SSE is fire-and-forget. A simpler approach covers most cases: a lightweight component that polls the backend on a fixed interval and updates its own display.
Solution
Section titled “Solution”Combine two triggers on a component to create a self-scheduling poll loop:
@Trigger(type = OnLoad, actionId = "X", timeoutMillis = N)— fires actionXN ms after the component loads.@Trigger(type = OnSuccess, actionId = "X", calledActionId = "X", timeoutMillis = N)— fires actionXagain N ms after each successful completion ofX.
The action returns new State(this), which pushes the updated component state to the frontend. Hydratable.hydrate() runs before each render to refresh data from the backend.
Structure
Section titled “Structure”Shell header └── MicroFrontend (/_forms/my-tasks) └── TasksWidget @Trigger OnLoad ──────────────────────────────────┐ @Trigger OnSuccess (calledActionId = refreshTasks) │ ↓ (after 5 s) │ refreshTasks() → State(this) │ hydrate() ← reads DB / service │ component() ← renders updated content │ └──────────────────────────────────────────────┘Implementation
Section titled “Implementation”1. The notification widget
Section titled “1. The notification widget”@UI(value = "/_forms/my-tasks")@Title("")@Service@RequiredArgsConstructor@Trigger(type = TriggerType.OnLoad, actionId = "refreshTasks", timeoutMillis = 5000)@Trigger(type = TriggerType.OnSuccess, actionId = "refreshTasks", calledActionId = "refreshTasks", timeoutMillis = 5000)@Action(id = "refreshTasks")public class TasksWidget implements Hydratable, ComponentTreeSupplier {
final TaskRepository repository; String content;
// Called before rendering — refresh data from the backend @Override public void hydrate(HttpRequest httpRequest) { long pending = repository.countPendingFor( JwtExtractor.getUsername(httpRequest).orElse(""));
if (pending > 0) { content = "<a href=\"#\" onclick=\"" + navScript() + "\" " + "style=\"animation: fade 2s ease-in-out infinite alternate;\">" + "You have " + pending + " task(s)!" + "</a>" + "<style>@keyframes fade{from{opacity:1}to{opacity:0}}</style>"; } else { content = "<a href=\"#\" onclick=\"" + navScript() + "\">No pending tasks</a>"; } }
// Returns updated state to the frontend — triggers re-render Object refreshTasks() { return new State(this); }
@Override public Component component(HttpRequest httpRequest) { return Text.builder().text("${state.content}").build(); }
private String navScript() { return "event.preventDefault(); this.dispatchEvent(new CustomEvent(" + "'navigation-requested',{detail:{" + "route:'/forms/tasks',consumedRoute:''," + "baseUrl:'/_forms',serverSideType:'io.example.FormsHome'" + "},bubbles:true,composed:true}))"; }}2. Embedding the widget in the shell
Section titled “2. Embedding the widget in the shell”Embed the widget as a MicroFrontend inside the shell’s widgets(). The widget lives in a separate service — the shell just points to its URL.
@UI("")public class ShellHome implements WidgetSupplier {
@Override public List<Component> widgets(HttpRequest httpRequest) { return List.of( HorizontalLayout.builder() .content(List.of( MicroFrontend.builder() .baseUrl("/_forms") .route("/my-tasks") .build(), // ... other header widgets (user menu, etc.) )) .style("align-items: flex-end;") .build() ); }}How the poll loop works
Section titled “How the poll loop works”| Step | What happens |
|---|---|
| Page loads | OnLoad trigger fires after timeoutMillis ms |
refreshTasks() runs | hydrate() reads the DB; action returns State(this) |
| Frontend receives new state | Component re-renders with updated content |
OnSuccess trigger fires | After timeoutMillis ms, calls refreshTasks() again |
| Loop continues indefinitely | Until the user navigates away or the component is unmounted |
The interval is set independently on each trigger, so you can load immediately but poll less frequently:
@Trigger(type = TriggerType.OnLoad, actionId = "poll", timeoutMillis = 0) // immediate@Trigger(type = TriggerType.OnSuccess, actionId = "poll", calledActionId = "poll", timeoutMillis = 10_000) // every 10 sKey interfaces and types
Section titled “Key interfaces and types”| Type | Role |
|---|---|
Hydratable | hydrate(HttpRequest) is called before each render — use it to populate fields from the backend |
State(this) | Return value from an action — pushes the component’s current field values to the frontend |
MicroFrontend | Embeds a remote @UI component inside the host shell |
WidgetSupplier | Interface on the shell class that injects components into the header widget area |
Variants
Section titled “Variants”- Badge count — render an integer and style it as a pill badge.
- System alert banner — render a full-width coloured banner when a critical condition is detected.
- Cross-service — the widget
@UIlives in a different microservice from the shell; the shell embeds it viaMicroFrontendpointing to that service’s base URL. - Auth-aware — read the JWT from
HttpRequestinhydrate()to filter tasks by the current user (see example above withJwtExtractor).
Principles served
Section titled “Principles served”- Preserve context — notifications appear in-place; the user is not redirected
- Progressive complexity — the widget is invisible when there is nothing to report
- Workflow over screens — one click takes the user directly to the pending-tasks screen