Writing Your First Desktop Application in JavaFX
JavaFX 13 min read
JavaFX left the JDK at Java 11, so the first problem is getting it on the module path at all. Then the Application lifecycle, the single thread that owns every node, and why launch() cannot be called twice.
The first obstacle to a JavaFX application is not code. Since Java 11 JavaFX has not shipped with the
JDK, so javafx.application.Application does not resolve on a stock OpenJDK build, and the error —
package javafx.application does not exist, reads like a broken installation.
Written against JavaFX 21 and Java 17.
Getting JavaFX onto the classpath
Two options.
A JDK that bundles it. Liberica Full, Azul Zulu FX and a few others ship JavaFX inside the JDK, which makes everything below work with no build configuration. Convenient for learning, and it ties the application to a specific JDK distribution.
OpenJFX as a dependency, which is the normal answer:
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21.0.2</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>21.0.2</version>
</dependency>
The artifacts are platform-specific. Maven resolves a classifier for the current machine automatically, which means a jar built on Linux does not run on Windows, a build producing artefacts for several platforms has to declare the classifiers explicitly.
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration>
<mainClass>com.example.HelloApp</mainClass>
</configuration>
</plugin>
mvn javafx:run
The plugin exists because JavaFX must be on the module path, not the classpath. Running the jar directly needs the flags spelled out:
java --module-path /path/to/javafx-sdk-21/lib \
--add-modules javafx.controls,javafx.fxml \
-jar app.jar
Omit them and the failure is Error: JavaFX runtime components are missing, and are required to run this application — which is a clearer message than most, and still not one that says “add these two
flags”.
The smallest application
package com.example;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.stage.Stage;
public class HelloApp extends Application {
@Override
public void start(Stage stage) {
Label label = new Label("Nothing clicked yet");
Button button = new Button("Click me");
button.setOnAction(event -> label.setText("Clicked"));
VBox root = new VBox(12, label, button);
root.setAlignment(Pos.CENTER);
root.setPadding(new Insets(24));
stage.setTitle("Hello JavaFX");
stage.setScene(new Scene(root, 320, 200));
stage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Three objects, and a hierarchy that never changes shape:
Stageis the window. The one passed tostartis the primary stage; others can be created for dialogs and secondary windows.Sceneis the contents of a window. One scene per stage at a time, swappable.- The scene graph is a tree of
Nodes below the scene’s root. Every control, every layout container and every shape is a node.
Calling stage.show() is what actually makes the window appear. Forget it and the application starts, holds a thread,
and displays nothing, a silent failure that looks like a crash.
The lifecycle
public class HelloApp extends Application {
@Override
public void init() throws Exception {
// runs on the launcher thread, before the UI exists
// load configuration, open a database connection
}
@Override
public void start(Stage stage) {
// runs on the JavaFX Application Thread; build and show the UI here
}
@Override
public void stop() throws Exception {
// runs on the JavaFX Application Thread as the application exits
// release resources
}
}
init runs on the launcher thread, not the FX thread, so it cannot touch any node, creating a
Stage or a Scene there throws. It is for work that must finish before the window appears.
stop runs when the last window closes, provided implicit exit is enabled (the default). It does not
run on System.exit(), so use Platform.exit() to shut down cleanly.
Platform.setImplicitExit(false) keeps the runtime alive with no windows open, which is what a
system-tray application needs, and what makes an application that “will not quit” when the last
window is closed.
launch() blocks until the application exits and can only be called once per JVM. A second call
throws IllegalStateException, which is why JavaFX applications are awkward to start from a test —
Platform.startup(Runnable) is the entry point for that case.
One thread owns the UI
Every node must be created and modified on the JavaFX Application Thread. Touching a live node
from another thread throws IllegalStateException: Not on FX application thread, and touching one
before it is attached to a scene often works, which makes the rule easy to learn wrongly.
Blocking that thread freezes the window:
button.setOnAction(event -> {
String result = callSlowService(); // the UI is frozen for the duration
label.setText(result);
});
The fix is to move the work off and post the result back:
button.setOnAction(event -> {
Task<String> task = new Task<>() {
@Override
protected String call() {
return callSlowService(); // background thread
}
};
task.setOnSucceeded(e -> label.setText(task.getValue())); // back on the FX thread
task.setOnFailed(e -> label.setText("Failed: " + task.getException().getMessage()));
new Thread(task).start();
});
Task is worth preferring over a raw thread plus Platform.runLater, because it gives the success
and failure callbacks on the FX thread for free, plus updateProgress and updateMessage for
binding to a progress bar.
Platform.runLater(runnable) is the low-level version and the right tool when a result arrives from
code you do not control:
Platform.runLater(() -> label.setText(value));
It queues rather than blocking, so flooding it from a tight loop makes the UI unresponsive in a different way: the queue is drained on the FX thread between frames, and thousands of pending runnables starve rendering. Batch the updates rather than posting one per item.
Packaging something a user can run
A JavaFX application distributed as a jar requires the recipient to have a JDK and to know the module
flags, which is not a viable ask. jlink and jpackage remove both.
jlink builds a runtime image containing only the modules the application uses, typically 40–60 MB
rather than a full JDK. jpackage wraps that image into a platform installer: a .msi or .exe on
Windows, a .dmg or .pkg on macOS, a .deb or .rpm on Linux.
mvn javafx:jlink
jpackage --type app-image \
--name HelloApp \
--runtime-image target/app \
--module com.example/com.example.HelloApp
Both need the application to be a proper JPMS module, which means a module-info.java:
module com.example {
requires javafx.controls;
requires javafx.fxml;
opens com.example to javafx.fxml; // reflection for FXML controllers
exports com.example;
}
The opens directive is the line that catches people. FXMLLoader instantiates the controller and
injects fields reflectively, and without opens it fails with an IllegalAccessException that names
the controller rather than the module system.
jpackage runs on the target platform only, it cannot cross-build, so a three-platform release
needs three machines or three CI runners.
Properties and binding
The feature that distinguishes JavaFX from older toolkits is that node state is observable:
Label counter = new Label();
IntegerProperty clicks = new SimpleIntegerProperty(0);
counter.textProperty().bind(clicks.asString("Clicked %d times"));
button.setOnAction(event -> clicks.set(clicks.get() + 1));
The label updates itself. No listener, no manual refresh, and the UI cannot drift out of step with the value because there is only one source of truth.
bind is one-directional and makes the target read-only, calling setText on a bound label throws.
bindBidirectional links two writable properties, which is what a form field and a model share.
Most of what a real application does with JavaFX is decide what binds to what, and a screen built that way has almost no code that updates the interface at all. The FXML walkthrough covers moving the layout out of Java, and styling with CSS covers the appearance.
Frequently asked questions
Why does javafx.application not resolve?
JavaFX has not shipped with the JDK since Java 11. Add the OpenJFX dependencies, or use a JDK distribution that bundles it.
What does “JavaFX runtime components are missing” mean?
JavaFX is on the classpath rather than the
module path. Pass --module-path and --add-modules javafx.controls,javafx.fxml, or use the
javafx-maven-plugin.
Why does my jar not run on another operating system?
The OpenJFX artifacts are platform-specific. Maven resolves the classifier for the build machine; a cross-platform build must declare them all.
What is the difference between Stage and Scene?
The Stage is the window; the Scene is its
contents. One scene is displayed per stage at a time, and it can be swapped.
Why does my window not appear?
stage.show() was not called. The application starts and holds a
thread with nothing visible.
Can I create UI objects in init()?
No. init runs on the launcher thread, and constructing a
Stage or Scene off the FX thread throws.
Why can launch() only be called once?
It starts the JavaFX runtime, which is a per-JVM singleton.
Use Platform.startup(Runnable) when a test or an existing application needs to start the toolkit.
Why does my background thread throw “Not on FX application thread”?
Every live node must be
modified on the JavaFX Application Thread. Use Platform.runLater, or a Task with
setOnSucceeded.
Task or Platform.runLater?
Task when you own the background work: it supplies success, failure
and progress callbacks on the FX thread. runLater when a result arrives from code you do not
control.
Why does setText throw on a bound label?
A one-directional bind makes the target read-only.
Unbind it first, or use bindBidirectional if both sides need to be writable.