Project: A Reactive JavaFX App
In this final lesson you integrate everything from the tutorial — properties, bindings, observable collections, event handling, CSS, and custom controls — into one working application: a Task Tracker. The UI updates itself automatically as data changes; you write almost no glue code because JavaFX bindings do the wiring for you.
The goal of this project is not to build the most feature-rich app imaginable. It is to show you what a binding-driven architecture looks like from end to end: a model that holds only Property and ObservableList fields, a view that binds to those fields, and controllers that mutate the model — never the UI directly.
Project Structure
Keep the code in four classes. This separation lets you reason about each layer in isolation:
Task.java — the model; every field is a JavaFX property.
TaskViewModel.java — holds the ObservableList of tasks and derived summary properties.
TaskTrackerApp.java — the Application subclass; builds and shows the scene.
styles.css — the visual skin.
The Model: Task.java
Every field is a Property so the UI can observe it directly. Use the standard JavaFX property pattern: a private field, a getter for the value, and a getter for the property object itself.
import javafx.beans.property.*;
public class Task {
private final StringProperty title = new SimpleStringProperty();
private final BooleanProperty completed = new SimpleBooleanProperty(false);
private final IntegerProperty priority = new SimpleIntegerProperty(1); // 1 low → 3 high
public Task(String title, int priority) {
this.title.set(title);
this.priority.set(priority);
}
// Value accessors
public String getTitle() { return title.get(); }
public boolean isCompleted() { return completed.get(); }
public int getPriority() { return priority.get(); }
// Property accessors — required for binding
public StringProperty titleProperty() { return title; }
public BooleanProperty completedProperty() { return completed; }
public IntegerProperty priorityProperty() { return priority; }
}
Why expose the property object? Returning titleProperty() lets callers write label.textProperty().bind(task.titleProperty()). If you only expose plain getters, no binding is possible.
The ViewModel: TaskViewModel.java
The ViewModel owns the list and computes summary statistics. Notice that completedCount is a computed binding — it recalculates automatically whenever any task in the list fires a change event.
import javafx.beans.binding.*;
import javafx.beans.property.*;
import javafx.collections.*;
public class TaskViewModel {
private final ObservableList<Task> tasks =
FXCollections.observableArrayList(
task -> new javafx.beans.Observable[] { task.completedProperty() }
);
// Derived: how many tasks are done
private final IntegerBinding completedCount = Bindings.createIntegerBinding(
() -> (int) tasks.stream().filter(Task::isCompleted).count(),
tasks
);
// Derived: summary label text
private final StringBinding summaryText = Bindings.createStringBinding(
() -> completedCount.get() + " / " + tasks.size() + " completed",
completedCount, tasks
);
// Derived: disable "Clear done" button when nothing is done
private final BooleanBinding nothingDone =
completedCount.isEqualTo(0);
public ObservableList<Task> getTasks() { return tasks; }
public IntegerBinding completedCountBinding() { return completedCount; }
public StringBinding summaryTextBinding() { return summaryText; }
public BooleanBinding nothingDoneBinding() { return nothingDone; }
public void addTask(String title, int priority) {
tasks.add(new Task(title, priority));
}
public void clearCompleted() {
tasks.removeIf(Task::isCompleted);
}
}
The key trick is passing an extractor lambda to observableArrayList. Without it, the list fires a change event only when tasks are added or removed. With the extractor, it also fires when completedProperty() changes inside an existing task — which is exactly what makes the live counter work.
Building the UI: TaskTrackerApp.java
The Application.start() method wires the ViewModel to the scene graph. Every dynamic element uses a binding; no event handler manually updates a label or button state.
import javafx.application.Application;
import javafx.geometry.*;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.stage.Stage;
public class TaskTrackerApp extends Application {
private final TaskViewModel vm = new TaskViewModel();
@Override
public void start(Stage stage) {
// ── Input row ──────────────────────────────────────────────
TextField titleField = new TextField();
titleField.setPromptText("New task title...");
ComboBox<Integer> priorityBox = new ComboBox<>();
priorityBox.getItems().addAll(1, 2, 3);
priorityBox.setValue(1);
Button addBtn = new Button("Add Task");
addBtn.setDefaultButton(true);
// Disable "Add" when title is blank
addBtn.disableProperty().bind(
titleField.textProperty().isEmpty()
);
addBtn.setOnAction(e -> {
vm.addTask(titleField.getText().trim(), priorityBox.getValue());
titleField.clear();
});
HBox inputRow = new HBox(8, titleField, priorityBox, addBtn);
HBox.setHgrow(titleField, Priority.ALWAYS);
inputRow.setAlignment(Pos.CENTER_LEFT);
// ── Task list ──────────────────────────────────────────────
ListView<Task> listView = new ListView<>(vm.getTasks());
listView.setCellFactory(lv -> new TaskCell());
VBox.setVgrow(listView, Priority.ALWAYS);
// ── Footer ─────────────────────────────────────────────────
Label summaryLabel = new Label();
summaryLabel.textProperty().bind(vm.summaryTextBinding());
summaryLabel.getStyleClass().add("summary-label");
Button clearBtn = new Button("Clear Completed");
clearBtn.disableProperty().bind(vm.nothingDoneBinding());
clearBtn.setOnAction(e -> vm.clearCompleted());
HBox footer = new HBox(8, summaryLabel, new Spacer(), clearBtn);
footer.setAlignment(Pos.CENTER_LEFT);
// ── Root ───────────────────────────────────────────────────
VBox root = new VBox(10, inputRow, listView, footer);
root.setPadding(new Insets(12));
root.getStyleClass().add("root-pane");
Scene scene = new Scene(root, 520, 400);
scene.getStylesheets().add(
getClass().getResource("styles.css").toExternalForm()
);
stage.setTitle("Reactive Task Tracker");
stage.setScene(scene);
stage.show();
// Seed a couple of sample tasks
vm.addTask("Read the JavaFX docs", 2);
vm.addTask("Build the project", 3);
vm.addTask("Write unit tests", 1);
}
public static void main(String[] args) { launch(args); }
}
Notice what is absent from every event handler. No handler manually sets a label text, enables or disables a button, or counts completed items. Every derived value is a binding — the framework computes and propagates updates automatically.
The Custom Cell: TaskCell.java
A custom ListCell renders each task with a checkbox, a label whose style reacts to completion, and a priority badge. Bindings connect the cell to the model so that toggling the checkbox updates the label styling immediately.
import javafx.geometry.*;
import javafx.scene.control.*;
import javafx.scene.layout.HBox;
public class TaskCell extends ListCell<Task> {
private final CheckBox checkBox = new CheckBox();
private final Label titleLabel = new Label();
private final Label priorityBadge = new Label();
private final HBox graphic = new HBox(8, checkBox, titleLabel, priorityBadge);
public TaskCell() {
graphic.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(titleLabel, javafx.scene.layout.Priority.ALWAYS);
priorityBadge.getStyleClass().add("badge");
}
@Override
protected void updateItem(Task task, boolean empty) {
super.updateItem(task, empty);
// Always unbind before rebinding to a new item
titleLabel.textProperty().unbind();
checkBox.selectedProperty().unbindBidirectional(
task == null ? null : task.completedProperty()
);
if (empty || task == null) {
setGraphic(null);
return;
}
titleLabel.textProperty().bind(task.titleProperty());
// Bidirectional binding: toggling the checkbox updates the model
checkBox.selectedProperty().bindBidirectional(task.completedProperty());
// Style the title based on completion
task.completedProperty().addListener((obs, wasCompleted, isNowCompleted) ->
updateStyle(isNowCompleted)
);
updateStyle(task.isCompleted());
// Priority badge text and colour class
priorityBadge.setText("P" + task.getPriority());
priorityBadge.getStyleClass().removeIf(c -> c.startsWith("p"));
priorityBadge.getStyleClass().add("p" + task.getPriority());
setGraphic(graphic);
}
private void updateStyle(boolean done) {
if (done) {
titleLabel.setStyle("-fx-strikethrough: true; -fx-text-fill: #999;");
} else {
titleLabel.setStyle("");
}
}
}
CSS: styles.css
JavaFX CSS gives the app a clean look without touching Java code. Priority badges use the class selectors you set in the cell.
.root-pane {
-fx-background-color: #f5f5f5;
-fx-font-family: "Segoe UI", sans-serif;
}
.summary-label {
-fx-font-size: 13px;
-fx-text-fill: #555;
}
.badge {
-fx-padding: 2 6 2 6;
-fx-background-radius: 4;
-fx-font-size: 11px;
-fx-text-fill: white;
-fx-font-weight: bold;
}
.p1 { -fx-background-color: #78909c; } /* low — grey-blue */
.p2 { -fx-background-color: #fb8c00; } /* medium — amber */
.p3 { -fx-background-color: #e53935; } /* high — red */
.list-view {
-fx-background-color: white;
-fx-border-color: #ddd;
-fx-border-radius: 4;
-fx-background-radius: 4;
}
.list-cell:selected {
-fx-background-color: #e3f2fd;
}
Running the Application
To run this project with the JavaFX SDK on the module path, use:
javac --module-path $PATH_TO_FX --add-modules javafx.controls \
Task.java TaskViewModel.java TaskCell.java TaskTrackerApp.java
java --module-path $PATH_TO_FX --add-modules javafx.controls \
TaskTrackerApp
JavaFX is not bundled with the JDK since Java 11. Download the JavaFX SDK from gluonhq.com/products/javafx and set $PATH_TO_FX to its lib/ directory. Alternatively, use Maven/Gradle with the org.openjfx:javafx-controls dependency, which also handles the module wiring.
What Makes This Architecture "Reactive"
The term reactive here has a precise meaning: the UI reacts to model state changes automatically, with no imperative update calls. Consider what happens when a user checks a task as done:
- The
CheckBox fires a change event.
- The bidirectional binding propagates that to
task.completedProperty().
- Because the
ObservableList has an extractor, it fires a list-change event.
completedCount recomputes — which triggers summaryText to recompute.
- The
summaryLabel text updates on screen.
nothingDone recomputes and may enable or disable clearBtn.
All six steps happen automatically as a result of changing one property. No controller method scans the list, no hand-written observer calls setText or setDisable. This is the payoff of the entire binding system you studied in this tutorial.
Summary
You have built a fully binding-driven JavaFX application. The key takeaways are: expose model state as Property objects; use an observable list with an extractor when cell-level changes must propagate; derive every computed value as a Binding rather than recomputing it by hand; and connect the UI to the model through bindings so handlers only mutate the model. Apply this pattern to any JavaFX project and your UI will always reflect the current state of your data.