Tutorial: a notes app
Build a small notes app step by step: a Java data class, a page of separate files and the bridge between them.
In this tutorial you build a small notes app: type a note, add it to a list, delete it. It is tiny on purpose. The point is to see every piece of a Jolt app in one place: a Java class that owns the data, a page made of separate files, and a bridge between them. The code on this page was run on Windows against a real window.
You should have read Getting started and Messaging first. The tutorial uses the Rpc class from the messaging page unchanged.
What we are building
- The page shows an input, an Add button and the list. It knows nothing about storage.
- The Java side keeps the notes and offers three functions:
notes.list,notes.add,notes.remove. - The bridge is the
Rpcclass in Java and thecall()function in the page.
This is the same shape as a large app: swap the in-memory list for a database and the plain page for Vue or React, and nothing else changes.
Step 1: the project
Use the pom.xml from Getting started, and add Gson, which Rpc uses for JSON:
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
</dependency>The folders:
notes/
pom.xml
src/main/java/com/example/notes/
NotesApp.java
NoteStore.java
Rpc.java <- copy it from the Messaging page
src/main/resources/ui/
index.html
style.css
app.jsStep 2: the data, in plain Java
Start with the part that has nothing to do with windows. A note is a record, and the store keeps a list of them:
package com.example.notes;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
public final class NoteStore {
public record Note(int id, String text) {}
private final List<Note> notes = new CopyOnWriteArrayList<>();
private final AtomicInteger nextId = new AtomicInteger(1);
public List<Note> list() { return List.copyOf(notes); }
public Note add(String text) {
if (text == null || text.isBlank()) throw new IllegalArgumentException("A note cannot be empty");
Note note = new Note(nextId.getAndIncrement(), text.strip());
notes.add(note);
return note;
}
public void remove(int id) { notes.removeIf(n -> n.id() == id); }
}The collections are thread-safe because Rpc runs your functions on worker threads, possibly at the same time. Throwing IllegalArgumentException for an empty note is how we will report a mistake to the page.
Step 3: the window and the bridge
package com.example.notes;
import io.github.b077as.jolt.*;
public final class NotesApp {
public static void main(String[] args) {
NoteStore store = new NoteStore();
Application app = Application.create(AppOptions.of("com.example.notes"));
try {
app.run(() -> {
WebWindow window = app.createWindow(WindowOptions.defaults()
.withTitle("Notes").withSize(520, 640).withDevTools(true));
new Rpc(window)
.on("notes.list", params -> store.list())
.on("notes.add", params -> store.add(params.get("text").getAsString()))
.on("notes.remove", params -> { store.remove(params.get("id").getAsInt()); return true; });
window.loadResources(AppResources.classpath("ui"));
});
} finally {
app.close();
}
}
}Read it top to bottom: build the store, create the app, in run create a window, register three Java functions on it, then show the files in ui. withDevTools(true) is there so you can inspect the page while learning; remove it for release.
Step 4: the page
Three small files in src/main/resources/ui.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Notes</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Notes</h1>
<form id="form">
<input id="text" placeholder="Write a note" autocomplete="off">
<button id="add">Add</button>
</form>
<p id="error" role="alert"></p>
<ul id="list"></ul>
<script type="module" src="app.js"></script>
</body>
</html>body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 28rem; }
form { display: flex; gap: .5rem; }
input { flex: 1; padding: .5rem; }
li { display: flex; justify-content: space-between; padding: .4rem 0; border-bottom: 1px solid #ddd; }
#error { color: #b00020; min-height: 1.2em; }// --- the bridge: call a Java function and wait for its answer ---
const pending = new Map();
let nextId = 1;
jolt.onMessage(raw => {
const reply = JSON.parse(raw);
const waiting = pending.get(reply.id);
if (!waiting) return;
pending.delete(reply.id);
if (reply.error) waiting.reject(new Error(reply.error));
else waiting.resolve(reply.result);
});
function call(method, params = {}) {
return new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
jolt.postMessage(JSON.stringify({ id, method, params }));
});
}
// --- the page ---
const list = document.getElementById('list');
const input = document.getElementById('text');
const error = document.getElementById('error');
function show(note) {
const li = document.createElement('li');
const span = document.createElement('span');
span.textContent = note.text; // textContent, never innerHTML
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = async () => { await call('notes.remove', { id: note.id }); li.remove(); };
li.append(span, del);
list.append(li);
}
document.getElementById('form').addEventListener('submit', async event => {
event.preventDefault();
error.textContent = '';
try {
show(await call('notes.add', { text: input.value }));
input.value = '';
} catch (e) {
error.textContent = e.message;
}
});
(await call('notes.list')).forEach(show);The first half of app.js is the bridge you already know. The second half is the app:
- Adding a note calls Java and shows what Java returns (including the id Java chose). If Java throws, the promise rejects and the message appears in the red paragraph.
- The last line asks Java for the saved notes when the page starts. It also shows the pattern from the messaging page: the page speaks first, so it is never too early for the answer.
- Note text goes into the page with
textContent. SettinginnerHTMLwith text a user typed is how scripts get injected.
Step 5: run it
Run NotesApp with --enable-native-access=ALL-UNNAMED, as on the first page. Type a note and press Enter: it appears in the list. Press Add with an empty box: the error from your Java code shows in red. Delete removes a note.
Notes vanish when you close the window because they live in a list. Replacing NoteStore with one that writes a file in the app's data folder (AppOptions.dataDirectory()) or a database changes nothing in the page.
What you used
| Piece | Where it is explained |
|---|---|
Application, createWindow, run | Getting started |
Messages and the Rpc layer | Messaging |
loadResources and the ui folder | Building the frontend |
| Threads and the UI thread | How Jolt works |
Where to go from here
- Prefer a page that works like a small website, with forms and
fetch? Serving files and requests builds this same app with aRouterand no bridge code. - Let the user export notes: file dialogs.
- Remind the user about something: notifications.
- Make it feel like your own app: window look, tray, and finally packaging.