Skip to content
CalliCoder

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:controller names the class the loader will instantiate. One per FXML file, on the root element.
  • fx:id is the name a field in the controller must match to receive the node. It is not the CSS id, 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:

  • ClassNotFoundException on an element name: a missing <?import ?> processing instruction, not a missing dependency.
  • NoSuchMethodException naming 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 text works because setText exists.

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".