J2CL: The Modern Successor to GWT for Java-to-JavaScript Compilation

J2CL (Java to Closure Compiler) is a modern source-to-source transpiler that converts Java bytecode to optimized JavaScript, serving as the spiritual successor to Google Web Toolkit (GWT). This guide explores J2CL's architecture, features, and how it improves upon GWT.

J2CL Overview and Architecture

What is J2CL?

J2CL is a transpiler that converts Java bytecode to Closure-style JavaScript, which can then be optimized by the Closure Compiler for production deployment.

Key Architecture Differences

// GWT Approach: Java source → JavaScript
// J2CL Approach: Java bytecode → Closure JS → Optimized JS

Setting Up J2CL

Maven Configuration

<!-- pom.xml -->
<project>
<properties>
<j2cl.version>0.21.0</j2cl.version>
</properties>
<dependencies>
<dependency>
<groupId>com.google.j2cl</groupId>
<artifactId>j2cl-base</artifactId>
<version>${j2cl.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.google.elemental2</groupId>
<artifactId>elemental2-core</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>com.google.elemental2</groupId>
<artifactId>elemental2-dom</artifactId>
<version>1.1.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>com.google.j2cl</groupId>
<artifactId>j2cl-maven-plugin</artifactId>
<version>${j2cl.version}</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

Bazel Build Configuration

# BUILD.bazel
load("@com_google_j2cl//build_defs:rules.bzl", "j2cl_library", "j2cl_application")
j2cl_library(
name = "myapp_lib",
srcs = ["MyApp.java"],
deps = [
"@com_google_elemental2//elemental2/core:core",
"@com_google_elemental2//elemental2/dom:dom",
],
)
j2cl_application(
name = "myapp",
entry_points = ["com.example.MyApp"],
deps = [":myapp_lib"],
)

Basic J2CL Application

Simple Web Application

// MyApp.java
package com.example;
import elemental2.dom.DomGlobal;
import elemental2.dom.HTMLButtonElement;
import elemental2.dom.HTMLDivElement;
import elemental2.dom.HTMLElement;
import elemental2.dom.Event;
public class MyApp {
public static void main(String[] args) {
DomGlobal.document.body.innerHTML = "";
// Create UI elements
HTMLDivElement container = (HTMLDivElement) DomGlobal.document.createElement("div");
HTMLButtonElement button = (HTMLButtonElement) DomGlobal.document.createElement("button");
HTMLDivElement output = (HTMLDivElement) DomGlobal.document.createElement("div");
// Configure elements
button.textContent = "Click me!";
button.style.cssText = """
padding: 10px 20px;
font-size: 16px;
margin: 10px;
""";
output.style.cssText = """
padding: 10px;
margin: 10px;
border: 1px solid #ccc;
""";
// Add event listener
button.addEventListener("click", (Event evt) -> {
int count = (int) (Math.random() * 100);
output.textContent = "Random number: " + count;
DomGlobal.console.log("Button clicked, generated: " + count);
});
// Build UI
container.appendChild(button);
container.appendChild(output);
DomGlobal.document.body.appendChild(container);
DomGlobal.console.log("J2CL application started successfully!");
}
}

DOM Manipulation with Elemental2

Working with Browser APIs

package com.example.dom;
import elemental2.dom.*;
import jsinterop.annotations.JsType;
public class TodoApp {
private final HTMLUListElement todoList;
private final HTMLInputElement input;
private int todoCount = 0;
public TodoApp() {
// Create main container
HTMLDivElement container = (HTMLDivElement) DomGlobal.document.createElement("div");
container.className = "todo-container";
// Create input and button
input = (HTMLInputElement) DomGlobal.document.createElement("input");
input.type = "text";
input.placeholder = "Enter a todo item...";
HTMLButtonElement addButton = (HTMLButtonElement) DomGlobal.document.createElement("button");
addButton.textContent = "Add Todo";
// Create todo list
todoList = (HTMLUListElement) DomGlobal.document.createElement("ul");
todoList.className = "todo-list";
// Add event listeners
addButton.addEventListener("click", evt -> addTodo());
input.addEventListener("keypress", evt -> {
if ("Enter".equals(((KeyboardEvent) evt).key)) {
addTodo();
}
});
// Build UI
container.appendChild(input);
container.appendChild(addButton);
container.appendChild(todoList);
DomGlobal.document.body.appendChild(container);
applyStyles();
}
private void addTodo() {
String text = input.value.trim();
if (!text.isEmpty()) {
createTodoItem(text);
input.value = "";
input.focus();
}
}
private void createTodoItem(String text) {
HTMLLIElement li = (HTMLLIElement) DomGlobal.document.createElement("li");
li.className = "todo-item";
HTMLSpanElement textSpan = (HTMLSpanElement) DomGlobal.document.createElement("span");
textSpan.textContent = ++todoCount + ". " + text;
HTMLButtonElement deleteBtn = (HTMLButtonElement) DomGlobal.document.createElement("button");
deleteBtn.textContent = "Delete";
deleteBtn.className = "delete-btn";
deleteBtn.addEventListener("click", evt -> {
todoList.removeChild(li);
DomGlobal.console.log("Removed todo: " + text);
});
li.appendChild(textSpan);
li.appendChild(deleteBtn);
todoList.appendChild(li);
}
private void applyStyles() {
HTMLElement style = (HTMLElement) DomGlobal.document.createElement("style");
style.textContent = """
.todo-container {
max-width: 500px;
margin: 20px auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.todo-container input {
width: 70%;
padding: 8px;
margin-right: 10px;
}
.todo-container button {
padding: 8px 16px;
background: #007cba;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.todo-list {
list-style: none;
padding: 0;
margin-top: 20px;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
margin: 5px 0;
background: #f5f5f5;
border-radius: 4px;
}
.delete-btn {
background: #dc3545 !important;
padding: 4px 8px !important;
font-size: 12px;
}
""";
DomGlobal.document.head.appendChild(style);
}
public static void main(String[] args) {
new TodoApp();
}
}

JavaScript Interoperability

JsInterop Annotations

package com.example.interop;
import jsinterop.annotations.JsConstructor;
import jsinterop.annotations.JsMethod;
import jsinterop.annotations.JsPackage;
import jsinterop.annotations.JsProperty;
import jsinterop.annotations.JsType;
// Using existing JavaScript libraries
@JsType(isNative = true, namespace = JsPackage.GLOBAL)
public class JavaScriptIntegration {
// Native JavaScript Math.random()
public static native double random();
// Using window.console
@JsMethod(namespace = JsPackage.GLOBAL)
public static native void alert(String message);
// Working with JSON
@JsMethod(namespace = JsPackage.GLOBAL)
public static native String JSONStringify(Object obj);
@JsMethod(namespace = JsPackage.GLOBAL) 
public static native Object JSONParse(String json);
}
// Custom JavaScript-interoperable type
@JsType
public class UserData {
@JsProperty
public String name;
@JsProperty
public int age;
@JsProperty
public String email;
@JsConstructor
public UserData() {}
@JsMethod
public String toJson() {
return JavaScriptIntegration.JSONStringify(this);
}
public static UserData fromJson(String json) {
return (UserData) JavaScriptIntegration.JSONParse(json);
}
}
// Using the interoperable types
public class InteropExample {
public static void main(String[] args) {
// Call native JavaScript functions
double randomValue = JavaScriptIntegration.random();
JavaScriptIntegration.alert("Random value: " + randomValue);
// Work with JSON
UserData user = new UserData();
user.name = "John Doe";
user.age = 30;
user.email = "[email protected]";
String json = user.toJson();
DomGlobal.console.log("User JSON: " + json);
UserData parsedUser = UserData.fromJson(json);
DomGlobal.console.log("Parsed user: " + parsedUser.name);
}
}

Advanced J2CL Features

Async/Await Support

package com.example.async;
import elemental2.promise.Promise;
import jsinterop.annotations.JsMethod;
import jsinterop.annotations.JsPackage;
import jsinterop.annotations.JsType;
@JsType(isNative = true, namespace = JsPackage.GLOBAL)
public class FetchApi {
public static native Promise<Response> fetch(String url);
}
@JsType(isNative = true, namespace = JsPackage.GLOBAL)
class Response {
public native Promise<String> text();
public native Promise<JavaScriptObject> json();
}
public class AsyncExample {
public Promise<String> fetchData(String url) {
return FetchApi.fetch(url)
.then(response -> {
if (!response.ok) {
throw new RuntimeException("HTTP error: " + response.status);
}
return response.text();
})
.then(text -> {
DomGlobal.console.log("Fetched data: " + text);
return Promise.resolve(text);
})
.catch_(error -> {
DomGlobal.console.error("Fetch error: " + error);
return Promise.reject(error);
});
}
public Promise<User[]> fetchUsers() {
return FetchApi.fetch("https://jsonplaceholder.typicode.com/users")
.then(Response::json)
.then(json -> {
// Process JSON data
User[] users = processUserData(json);
return Promise.resolve(users);
});
}
private native User[] processUserData(JavaScriptObject json);
public static void main(String[] args) {
AsyncExample example = new AsyncExample();
example.fetchData("https://api.example.com/data")
.then(data -> {
DomGlobal.console.log("Data received: " + data);
return null;
});
}
}
// User record for data binding
record User(int id, String name, String email) {}

Web Components with J2CL

package com.example.components;
import elemental2.dom.*;
import jsinterop.annotations.JsType;
@JsType
public class CustomButton extends HTMLElement {
private HTMLButtonElement button;
private int clickCount = 0;
public CustomButton() {
// Create shadow DOM
ShadowRoot shadow = this.attachShadow(new ShadowRootInit.Builder()
.setMode("open")
.build());
// Create button element
button = (HTMLButtonElement) DomGlobal.document.createElement("button");
button.style.cssText = """
padding: 12px 24px;
background: linear-gradient(45deg, #667eea, #764ba2);
color: white;
border: none;
border-radius: 25px;
cursor: pointer;
font-size: 16px;
transition: transform 0.2s;
""";
updateButtonText();
// Add hover effects
button.addEventListener("mouseenter", evt -> {
button.style.transform = "scale(1.05)";
});
button.addEventListener("mouseleave", evt -> {
button.style.transform = "scale(1)";
});
// Add click handler
button.addEventListener("click", evt -> {
clickCount++;
updateButtonText();
// Dispatch custom event
CustomEvent customEvent = new CustomEvent("buttonClicked");
customEvent.setDetail(new ClickDetail(clickCount, System.currentTimeMillis()));
this.dispatchEvent(customEvent);
});
shadow.appendChild(button);
}
private void updateButtonText() {
button.textContent = "Clicked: " + clickCount + " times";
}
// Attribute change handling
public static void observedAttributes() {
return new String[]{"color-scheme"};
}
public void attributeChangedCallback(String name, String oldValue, String newValue) {
if ("color-scheme".equals(name)) {
applyColorScheme(newValue);
}
}
private void applyColorScheme(String scheme) {
switch (scheme) {
case "dark":
button.style.background = "linear-gradient(45deg, #2c3e50, #34495e)";
break;
case "light":
button.style.background = "linear-gradient(45deg, #667eea, #764ba2)";
break;
}
}
// Custom event detail class
@JsType
public static class ClickDetail {
public final int count;
public final double timestamp;
public ClickDetail(int count, double timestamp) {
this.count = count;
this.timestamp = timestamp;
}
}
}
// Component registry
public class WebComponentRegistry {
public static void registerComponents() {
DomGlobal.customElements.define("custom-button", CustomButton.class);
}
public static void main(String[] args) {
registerComponents();
// Create and use custom element
CustomButton button = (CustomButton) DomGlobal.document.createElement("custom-button");
button.setAttribute("color-scheme", "dark");
button.addEventListener("buttonClicked", evt -> {
CustomButton.ClickDetail detail = (CustomButton.ClickDetail) ((CustomEvent) evt).detail;
DomGlobal.console.log("Button clicked " + detail.count + " times at " + detail.timestamp);
});
DomGlobal.document.body.appendChild(button);
}
}

State Management Patterns

Reactive State Management

package com.example.state;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class Store<T> {
private T state;
private final List<Consumer<T>> listeners = new ArrayList<>();
public Store(T initialState) {
this.state = initialState;
}
public T getState() {
return state;
}
public void setState(T newState) {
this.state = newState;
notifyListeners();
}
public void updateState(Consumer<T> updater) {
updater.accept(state);
notifyListeners();
}
public void subscribe(Consumer<T> listener) {
listeners.add(listener);
}
public void unsubscribe(Consumer<T> listener) {
listeners.remove(listener);
}
private void notifyListeners() {
for (Consumer<T> listener : listeners) {
listener.accept(state);
}
}
}
// Application state
record AppState(int counter, String message, boolean loading) {
public AppState withCounter(int newCounter) {
return new AppState(newCounter, this.message, this.loading);
}
public AppState withMessage(String newMessage) {
return new AppState(this.counter, newMessage, this.loading);
}
public AppState withLoading(boolean newLoading) {
return new AppState(this.counter, this.message, newLoading);
}
}
// Stateful component
public class CounterApp {
private final Store<AppState> store;
private final HTMLDivElement container;
public CounterApp() {
this.store = new Store<>(new AppState(0, "Ready", false));
this.container = (HTMLDivElement) DomGlobal.document.createElement("div");
initializeUI();
setupSubscriptions();
}
private void initializeUI() {
container.innerHTML = """
<div class="counter-app">
<h1>Counter: <span id="counter-value">0</span></h1>
<p id="status-message">Ready</p>
<button id="increment-btn">Increment</button>
<button id="decrement-btn">Decrement</button>
<button id="reset-btn">Reset</button>
</div>
""";
// Add event listeners
container.querySelector("#increment-btn")
.addEventListener("click", evt -> increment());
container.querySelector("#decrement-btn")
.addEventListener("click", evt -> decrement());
container.querySelector("#reset-btn")
.addEventListener("click", evt -> reset());
DomGlobal.document.body.appendChild(container);
}
private void setupSubscriptions() {
store.subscribe(state -> {
updateCounterDisplay(state.counter());
updateStatusMessage(state.message());
});
}
private void increment() {
store.updateState(state -> {
int newCounter = state.counter() + 1;
String message = "Counter incremented to " + newCounter;
return state.withCounter(newCounter).withMessage(message);
});
}
private void decrement() {
store.updateState(state -> {
int newCounter = state.counter() - 1;
String message = "Counter decremented to " + newCounter;
return state.withCounter(newCounter).withMessage(message);
});
}
private void reset() {
store.setState(new AppState(0, "Counter reset", false));
}
private void updateCounterDisplay(int value) {
HTMLElement counterElement = (HTMLElement) container.querySelector("#counter-value");
counterElement.textContent = String.valueOf(value);
}
private void updateStatusMessage(String message) {
HTMLElement messageElement = (HTMLElement) container.querySelector("#status-message");
messageElement.textContent = message;
}
public static void main(String[] args) {
new CounterApp();
}
}

Testing J2CL Applications

Unit Testing Setup

// Test configuration for J2CL
package com.example.test;
import com.google.j2cl.junit.apt.J2clTestInput;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
@RunWith(com.google.j2cl.junit.J2clTestRunner.class)
@J2clTestInput(TodoAppTest.class)
public class TodoAppTest {
@Test
public void testTodoItemCreation() {
// Test business logic without DOM dependencies
TodoApp app = new TodoApp();
// Test methods that don't require browser environment
}
@Test 
public void testStateManagement() {
Store<AppState> store = new Store<>(new AppState(0, "Test", false));
store.subscribe(state -> {
assertEquals(0, state.counter());
});
store.setState(new AppState(1, "Updated", false));
}
}

Build and Deployment

Optimized Production Build

# With Bazel
bazel build //src/main/java/com/example:myapp
# With Maven  
mvn clean compile j2cl:compile
# Output will be in target/j2cl-out/ for Maven
# or bazel-bin/ for Bazel

Integration with Modern Bundlers

// webpack.config.js for J2CL output
module.exports = {
entry: './target/j2cl-out/myapp.js',
output: {
filename: 'app.bundle.js',
path: __dirname + '/dist'
},
optimization: {
minimizer: [
new (require('closure-compiler'))({
compilation_level: 'ADVANCED',
language_in: 'ECMASCRIPT_2019',
language_out: 'ECMASCRIPT_2019'
})
]
}
};

Comparison: J2CL vs GWT

FeatureGWTJ2CL
InputJava source codeJava bytecode
OutputJavaScriptClosure-style JavaScript
OptimizationGWT compilerClosure Compiler
InteropJSNIJsInterop
Build ToolCustomBazel, Maven, Gradle
SizeLarger outputHighly optimized
PerformanceGoodExcellent with Closure

Conclusion

J2CL represents the modern evolution of Java-to-JavaScript compilation with significant advantages over GWT:

Key Benefits:

  • Better optimization through Closure Compiler
  • Superior interoperability with modern JavaScript
  • Modern tooling integration with Bazel, Maven, and Gradle
  • Smaller output sizes with advanced optimizations
  • Type-safe JavaScript interop via JsInterop

Use Cases:

  • Large-scale web applications requiring type safety
  • Code sharing between server and client
  • Teams with strong Java expertise building web apps
  • Applications requiring advanced JavaScript optimization

Migration Path from GWT:

  1. Start with shared business logic
  2. Gradually replace UI components with Elemental2
  3. Convert JSNI to JsInterop
  4. Update build configuration
  5. Leverage modern JavaScript tooling

J2CL provides a robust, future-proof path for Java developers to build high-performance web applications while leveraging the entire Java ecosystem and tooling.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper