Creating JavaFX User Interfaces Using FXML
Published Updated JavaFX 13 min read
The controller is instantiated by the loader rather than by you, @FXML fields are null until after the constructor, and the module system needs an opens directive before any of it works.
FXML moves the layout out of Java and into a markup file, which separates structure from behaviour
and makes a visual editor possible. The cost is a set of rules about when things exist: the
controller is constructed before the interface, so every @FXML field is null in the constructor,
and the module system has to be told to permit the reflection that fills them.
Written against JavaFX 21 and Java 17.
The markup
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>
<GridPane xmlns:fx="http://javafx.com/fxml"
fx:controller="com.example.RegistrationController"
hgap="10" vgap="10" alignment="CENTER">
<padding><Insets top="24" right="24" bottom="24" left="24"/></padding>
<Label text="Email" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
<TextField fx:id="emailField" GridPane.rowIndex="0" GridPane.columnIndex="1"/>
<Label text="Password" GridPane.rowIndex="1" GridPane.columnIndex="0"/>
<PasswordField fx:id="passwordField" GridPane.rowIndex="1" GridPane.columnIndex="1"/>
<Button text="Register" onAction="#handleRegister"
GridPane.rowIndex="2" GridPane.columnIndex="1"/>
<Label fx:id="statusLabel" GridPane.rowIndex="3" GridPane.columnIndex="1"/>
</GridPane>
Three pieces of the fx: namespace vocabulary do all of the work here:
fx:controllernames the class the loader will instantiate. One per FXML file, on the root element.fx:idis the name a field in the controller must match to receive the node. It is not the CSSid, which is a separate attribute, a node can have both, and they are unrelated.onAction="#method"binds an event to a controller method. The#is required.
The <?import ?> processing instructions are how the loader resolves the element names. A wildcard
import works and slows loading slightly; a missing one produces ClassNotFoundException naming the
element.
Anything settable through a JavaBean setter is settable as an attribute: text, promptText,
disable, prefWidth. Anything more complex is a child element, which is why Insets is nested
inside <padding>.
The controller
package com.example;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
public class RegistrationController {
@FXML private TextField emailField;
@FXML private PasswordField passwordField;
@FXML private Label statusLabel;
@FXML
private void initialize() {
statusLabel.setText("");
emailField.setPromptText("[email protected]");
}
@FXML
private void handleRegister() {
String email = emailField.getText().trim();
if (!email.contains("@")) {
statusLabel.setText("Enter a valid email address");
return;
}
if (passwordField.getText().length() < 8) {
statusLabel.setText("Password must be at least 8 characters");
return;
}
statusLabel.setText("Registered " + email);
}
}
The fields and methods can be private: @FXML is what grants the loader reflective access, and
keeping them private stops anything else reaching in.
initialize() is the lifecycle hook, and it is the answer to the most common FXML question. The
controller is constructed before the nodes are created and injected, so a constructor sees every
@FXML field as null. initialize() runs after injection, which is where setup belongs.
An event handler method takes either no arguments or a single ActionEvent. Both signatures work;
the loader picks whichever exists. A typo in the method name is not caught at compile time, FXML is
resolved at load, so it surfaces as a LoadException the first time the view is opened rather than
when the file is saved.
Loading it
@Override
public void start(Stage stage) throws IOException {
FXMLLoader loader = new FXMLLoader(getClass().getResource("/registration.fxml"));
Parent root = loader.load();
RegistrationController controller = loader.getController();
stage.setScene(new Scene(root));
stage.setTitle("Register");
stage.show();
}
getResource returns null for a path that is not on the classpath, and FXMLLoader then fails with a
NullPointerException about the location rather than about the file. The FXML belongs in
src/main/resources, and the leading slash makes the path absolute rather than relative to the
class’s package.
loader.getController() is only valid after load(). Before that it returns null, which is the
second most common FXML mistake.
Note that a FXMLLoader loads once. Calling load() twice on the same instance throws; create a new
loader for a second copy of the view.
The module system
If the project has a module-info.java, which jpackage requires — reflection into the controller
must be permitted explicitly:
module com.example {
requires javafx.controls;
requires javafx.fxml;
opens com.example to javafx.fxml;
exports com.example;
}
Without opens, loading fails with an IllegalAccessException naming the controller class. exports
alone is not enough. It permits compile-time access, not the deep reflection @FXML injection
needs.
Controllers with dependencies
The loader calls the controller’s no-argument constructor, which is a problem the moment the
controller needs a service. setControllerFactory replaces that:
FXMLLoader loader = new FXMLLoader(getClass().getResource("/registration.fxml"));
loader.setControllerFactory(type -> {
if (type == RegistrationController.class) {
return new RegistrationController(userService);
}
throw new IllegalArgumentException("Unknown controller " + type);
});
Parent root = loader.load();
The factory receives the class named in fx:controller and returns an instance. This is the hook a
dependency-injection container plugs into, a Spring application supplies
context::getBean and every controller becomes a managed bean.
The alternative is a setter called after load(), which works and means the controller is briefly in
an incomplete state, with initialize() having already run before the dependency arrived.
Composing views
<VBox>
<fx:include source="header.fxml" fx:id="header"/>
<fx:include source="content.fxml"/>
</VBox>
Each included file has its own controller. The parent can reach an included controller through a
field named <fx:id>Controller:
@FXML private HeaderController headerController; // for fx:id="header"
That naming convention is exact and there is no annotation to override it. An include without an
fx:id is still loaded and its controller is simply unreachable from the parent, which is fine for a
self-contained fragment and a silent dead end when it is not.
fx:include also resolves its source relative to the including file, unlike getResource, so a
sibling file needs no path at all and one in a subdirectory needs a relative one.
Binding instead of handling
The controller above reads values out of the fields when the button is pressed. The alternative is to bind the fields to a model, which removes the reading step and makes validation continuous:
public class RegistrationController {
@FXML private TextField emailField;
@FXML private PasswordField passwordField;
@FXML private Button registerButton;
@FXML private Label statusLabel;
@FXML
private void initialize() {
BooleanBinding invalid = Bindings.createBooleanBinding(
() -> !emailField.getText().contains("@")
|| passwordField.getText().length() < 8,
emailField.textProperty(), passwordField.textProperty());
registerButton.disableProperty().bind(invalid);
statusLabel.textProperty().bind(
Bindings.when(invalid).then("Complete both fields").otherwise(""));
}
}
The button disables itself as the user types and re-enables when the input is valid. Nothing polls
and no handler updates the state: the dependencies passed to createBooleanBinding tell the
framework what to re-evaluate on.
The trade is that the validity rule now lives in the controller rather than in the domain. For
anything beyond field-level checks, the better shape is a model object exposing its own
BooleanProperty valid, with the controller binding to that, which also makes the rule testable
without a UI.
Errors the loader reports badly
load() wraps almost everything in a LoadException, and the useful part is the cause. Three common
ones and what they actually mean:
ClassNotFoundExceptionon an element name: a missing<?import ?>processing instruction, not a missing dependency.NoSuchMethodExceptionnaming the controller, the class has no no-argument constructor, and no controller factory was supplied.- A message about an unmodifiable property: The attribute has no matching setter. FXML attributes
map to JavaBean setters, so
textworks becausesetTextexists.
Reading e.getCause() rather than the top-level message is what turns each of these from a puzzle
into a one-line fix.
Scene Builder
Scene Builder is a drag-and-drop editor that reads and writes the same FXML. It is worth using for layout and worth being careful with: it reformats the file wholesale on save, so a hand-edited comment or an unusual construct can disappear. Treat the FXML as jointly owned, and keep logic in the controller where the tool never touches it.
Related: the application skeleton, building the same form in Java and CSS styling.
Frequently asked questions
Why are my @FXML fields null in the constructor?
The controller is constructed before the nodes
exist. Injection happens afterwards, so setup belongs in initialize().
When does initialize() run?
After every @FXML field has been injected and before load()
returns. It is the correct place for any code touching the nodes.
Can @FXML fields be private?
Yes, and they should be. The annotation is what grants the loader reflective access.
What is the difference between fx:id and id?
fx:id names the field the node is injected into.
id is the CSS identifier. They are separate attributes and can differ.
Why does the loader throw a NullPointerException about the location?
getResource returned null —
the FXML is not on the classpath at that path. Put it in src/main/resources and use a
leading-slash absolute path.
Why does getController() return null?
It was called before load(). The controller does not exist
until the file has been loaded.
Why does FXML fail with IllegalAccessException in a modular project?
The controller’s package is
not opened to javafx.fxml. Add opens com.example to javafx.fxml;, exports is not sufficient.
How do I pass a dependency into a controller?
loader.setControllerFactory, which replaces the
default no-argument construction. It is also the hook a DI container uses.
Can I load the same FXMLLoader twice?
No. Create a new loader for each instance of the view.
How do I access the controller of an included FXML?
Declare a field named after the include’s
fx:id with Controller appended, headerController for fx:id="header".