Hilla Basics Tutorial
This tutorial teaches you the core concepts of Hilla while you build a full-stack application for managing to-do items. After completing the tutorial, you are ready to start experimenting on your own or continue to more advanced tutorials.
What You Need
About 10 minutes
Node 16.14 or higher
JDK 11 or higher (For example, Eclipse Temurin JDK).
Visual Studio Code is used in this tutorial. See the setup instructions on YouTube. You can use any IDE that supports Java and TypeScript development.
Create a Hilla Project
Use the Vaadin CLI to create a new project:
npx @vaadin/cli init --preset hilla-tutorial hilla-todo
The pre-configured starter project includes:
An empty
Todo
viewH2 database and Spring Data JPA dependencies
Importing the Hilla Project Into VS Code
Unzip the downloaded file and open the project in your IDE. The instructions in this tutorial assume you use VS Code.
Open the project by either:
navigating to the project folder and running
code .
(note the period), orchoosing File > Open… in VS Code and selecting the project folder.
We recommend installing the following plugins in VS Code for an optimal development experience:
VS Code should automatically suggest these for you when you open the project.
Project Structure and Architecture
Hilla projects are based on Spring Boot and use Maven for project management.
The two important folders be aware of are:
frontend
This is where your views and frontend code live.
src
This is where your Java backend code lives.
The key files in a Hilla application are:
pom.xml
The project configuration file, which defines dependencies.
frontend/index.html
The bootstrap page. You do not usually need to change this.
frontend/index.ts
Defines routing.
src/main/java/com/example/application/Application.java
Runs the Spring Boot application.
Defining the Data Model and Service Layer
Begin by setting up the data model and services for accessing the database.
You can do this in two steps:
Define an entity.
Create a repository for accessing the database.
This tutorial shows how to use an in-memory H2 database and JPA for persistence. The starter you downloaded already includes the needed dependencies in pom.xml.
Define a JPA entity class for the data model, by creating a new file, Todo.java, in src/main/java/com/example/application
with the following content:
package com.example.application;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.constraints.NotBlank;
@Entity 1
public class Todo {
@Id
@GeneratedValue
private Integer id;
private boolean done = false;
@NotBlank 2
private String task;
public Todo() {}
public Todo(String task) {
this.task = task;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public boolean isDone() {
return done;
}
public void setDone(boolean done) {
this.done = done;
}
public String getTask() {
return task;
}
public void setTask(String task) {
this.task = task;
}
}
Turn the class into a JPA entity with an
@Entity
annotation.Add a
@NotBlank
Java bean validation annotation to enforce validity both in the view and on the server.
Next, create a repository for accessing the database. You only need to provide an interface with type information; Spring Data takes care of the implementation.
Create a new file, TodoRepository.java, in src/main/java/com/example/application
, with the following contents:
package com.example.application;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TodoRepository extends JpaRepository<Todo, Integer> {
}
You now have all the necessary backend code in place to start building a UI.
Run the project from the command line with the following command:
./mvnw
The first time you run the application, it may take up to a few minutes, as Hilla downloads all the dependencies and builds a frontend bundle. Subsequent builds do not download dependencies, so that they are much faster.
When the build has finished, you should see the application running on http://localhost:8080.
Create a Typed Server Endpoint
One of the key features of Hilla is type-safe server access through endpoints.
When you define an @Endpoint
, Hilla creates the needed REST-like endpoints, secures them, and generates TypeScript interfaces for all the data types and public methods used.
Having full-stack type safety helps you stay productive through autocomplete and helps guard against accidentally breaking the UI when the data model changes on the server.
Create a new file, TodoEndpoint.java, in src/main/java/com/example/application
:
package com.example.application;
import java.util.List;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import dev.hilla.Endpoint;
import dev.hilla.Nonnull;
@Endpoint 1
@AnonymousAllowed 2
public class TodoEndpoint {
private TodoRepository repository;
public TodoEndpoint(TodoRepository repository) { 3
this.repository = repository;
}
public @Nonnull List<@Nonnull Todo> findAll() { 4
return repository.findAll();
}
public Todo save(Todo todo) {
return repository.save(todo);
}
}
Annotating a class with
@Endpoint
exposes it as a service for client-side views. All public methods of an endpoint are callable from TypeScript.By default, endpoint access requires an authenticated user.
@AnonymousAllowed
enables access for anyone. See Configuring Security for more information on endpoint security.Use Spring to automatically inject the
TodoRepository
dependency for database access.Using the
@Nonnull
annotation ensures that the TypeScript generator does not interpret these values as possiblyundefined
.
Save the file and ensure the change is loaded.
You should see log output from the reload in the console.
It should end with the message, Frontend compiled successfully
.
If you did not have the server running, or if something failed, (re)start the server with the mvn
command.
Building the Todo View Component
Hilla uses the Lit library for client-side views. Lit is a lightweight and highly performant library for building reactive components with declarative templates.
You can learn the basics of Lit in the article Lit basics.
Next, create a view for adding and viewing to-do items.
Open frontend/views/todo/todo-view.ts and replace its contents with the following:
import { html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
1
import '@vaadin/vaadin-text-field';
import '@vaadin/vaadin-button';
import '@vaadin/vaadin-checkbox';
import { Binder, field } from '@hilla/form';
import Todo from 'Frontend/generated/com/example/application/Todo';
import TodoModel from 'Frontend/generated/com/example/application/TodoModel';
import { TodoEndpoint } from 'Frontend/generated/endpoints';
import { View } from '../view';
@customElement('todo-view') 2
export class TodoView extends View { 3
}
Import the UI components, helpers, and generated TypeScript models required for building the view.
Register the new component with the browser. This makes it available as
<todo-view>
. The routing in index.ts is already set up to show it when you navigate to the application.Define the component class that extends from View.
Defining the View State
Inside the TodoView class, define the view state as follows:
@state()
private todos: Todo[] = []; 1
private binder = new Binder(this, TodoModel); 2
The list of Todo items is private and decorated with
@state()
, so Lit observes it for changes.A Hilla Binder is used to handle the form state for creating new Todo objects. TodoModel is automatically generated by Hilla. This describes the data types and validations that Binder needs. Read more about forms in Binding Data to Forms.
Defining CSS
Define some padding on the view.
Web Components have a default display
value of inline
, which is rarely what you want.
Set it to block
instead.
You can define styles in the themes/hilla-todo/styles.css file.
Read more about CSS in Styling With CSS.
todo-view {
display: block;
padding: var(--lumo-space-m) var(--lumo-space-l); 1
}
The
padding
property is defined using the spacing properties to be consistent with the rest of the app.
Defining the HTML Template
Define a render() method that returns an html
template literal inside the class.
render() {
return html`
`;
}
Add the following code within the html
template:
return html`
<div class='form'>
<vaadin-text-field
${field(this.binder.model.task)}
></vaadin-text-field> 1
<vaadin-button
theme='primary'
@click=${this.createTodo 2}
?disabled=${this.binder.invalid 3}
>Add</vaadin-button>
</div>
`;
The Text Field component is bound to the
task
property of a Todo using${field(this.binder.model.task)}
. You can read more about forms in Binding Data to Forms.The click event of the Add button is bound to the createTodo() method.
The button is disabled if the form is invalid.
Right underneath the previous <div>
, add the following code:
return html`
<div class="todos">
${this.todos.map((todo) => html` 1
<div class="todo">
<vaadin-checkbox
?checked=${todo.done 2}
@checked-changed=${(e: CustomEvent) => 3
this.updateTodoState(todo, e.detail.value)}
></vaadin-checkbox>
<span>${todo.task}</span>
</div>
`)}
</div>
`;
The existing
todo
items are shown by mapping thetodos
array to Lit templates. The template for a single Todo contains a checkbox and the task text.Bind the
checked
boolean attribute to thedone
property on thetodo
.Call the updateTodoState() method, with the
todo
and the new value, whenever the checked value changes.
Updating the View State and Calling the Backend
Below the render() method in the TodoView class, add a connectedCallback() lifecycle callback to initialize the view when it is attached to the DOM.
async connectedCallback() { 1
super.connectedCallback(); 2
this.todos = await TodoEndpoint.findAll(); 3
}
Use an async function to make it easier to handle asynchronous code.
Remember to call the superclass method.
The getTodos() method is automatically generated by Hilla based on the method in TodosEndpoint.java. The method was imported in the head of the file. The
await
keyword waits for the server response without blocking the UI.
Below the connectedCallback(), add another method to handle the creation of a new Todo.
async createTodo() {
const createdTodo = await this.binder.submitTo(TodoEndpoint.save); 1
if (createdTodo) {
this.todos = [...this.todos, createdTodo]; 3
this.binder.clear(); 3
}
}
Use
binder
to submit the form to TodoEndpoint. The Binder validates the input before posting it, and the server revalidates it.Update the state with a new array that includes the saved Todo. This re-renders the view.
Clear the form input.
Finally, add a method for updating the todo
state right below createTodo():
updateTodoState(todo: Todo, done: boolean) {
todo.done = done;
const updatedTodo = { ...todo }; 1
this.todos = this.todos.map((t) => (t.id === todo.id ? updatedTodo : t)); 2
TodoEndpoint.save(updatedTodo); 3
}
Create a new Todo with the updated
done
state.Update the local
todos
array with the new state. Themap
operator creates a new array where the changedtodo
is swapped out. This re-renders the view.Save the updated
todo
to the server.
Complete View Code
The completed view code is as follows:
import { html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@vaadin/vaadin-text-field';
import '@vaadin/vaadin-button';
import '@vaadin/vaadin-checkbox';
import { Binder, field } from '@hilla/form';
import Todo from 'Frontend/generated/com/example/application/Todo';
import TodoModel from 'Frontend/generated/com/example/application/TodoModel';
import { TodoEndpoint } from 'Frontend/generated/endpoints';
import { View } from '../view';
@customElement('todo-view')
export class TodoView extends View {
@state()
private todos: Todo[] = [];
private binder = new Binder(this, TodoModel);
render() {
return html`
<div class="form">
<vaadin-text-field ${field(this.binder.model.task)}></vaadin-text-field>
<vaadin-button theme="primary" @click=${this.createTodo} ?disabled=${this.binder.invalid}>
Add
</vaadin-button>
</div>
<div class="todos">
${this.todos.map(
(todo) => html`
<div class="todo">
<vaadin-checkbox
?checked=${todo.done}
@checked-changed=${(e: CustomEvent) => this.updateTodoState(todo, e.detail.value)}></vaadin-checkbox>
<span>${todo.task}</span>
</div>
`)}
</div>
`;
}
async connectedCallback() {
super.connectedCallback();
this.todos = await TodoEndpoint.findAll();
}
async createTodo() {
const createdTodo = await this.binder.submitTo(TodoEndpoint.save);
if (createdTodo) {
this.todos = [...this.todos, createdTodo];
this.binder.clear();
}
}
updateTodoState(todo: Todo, done: boolean) {
todo.done = done;
const updatedTodo = { ...todo };
this.todos = this.todos.map((t) => (t.id === todo.id ? updatedTodo : t));
TodoEndpoint.save(updatedTodo);
}
}
Run the Completed Application
Start your server with the ./mvnw
command if you do not already have it running.
Open http://localhost:8080 in your browser.
You should now have a fully functional to-do application.
Notice that when you refresh the browser, it keeps the same todo
items, as they are persisted in the database.
Optional: Deploy the Application to Heroku
The following steps are optional. You can follow them to deploy your application to Heroku, a cloud deployment platform that offers a free tier that does not require a credit card.
Preparing the Application for Production
It’s important to build a separate production-optimized version of the application before deploying it. In development mode, Hilla has a live-reload widget, debug logging, and uses a quick, but unoptimized, frontend build that includes source maps for easy debugging. Unoptimized frontend bundles can contain several megabytes of JavaScript.
The pom.xml
build includes a production
profile configuration that prepares an optimized build that’s ready for production.
Using a PostgreSQL Database in Production
During development, the application has used an in-memory H2 database. It is convenient and works well for a single user. In production, you want to use something more robust and persistent. Heroku’s free tier supports PostgreSQL, so can configure your application to use that.
First, add the PostgreSQL dependency in the production profile of pom.xml
:
<profile>
<id>production</id>
<!-- Omitted -->
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
</profile>
Next, configure how JPA should handle schema generation.
Add the following two properties to the end of application.properties
.
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=create-drop
Creating a Heroku Account and Installing Heroku CLI
Complete the following steps to create a Heroku account and install the Heroku CLI.
Go to https://signup.heroku.com/, create a new account, and verify your email.
Go to https://devcenter.heroku.com/articles/heroku-cli and follow the instructions for installing the CLI on your operating system.
Deploying a Hilla Application to Heroku
Use the Heroku CLI to create and deploy your application.
Log in:
heroku login
Configure the correct Java version:
echo "java.runtime.version=11" > system.properties
Install the Heroku Java plugin:
heroku plugins:install java
Create a new app. Replace APPNAME with a name of your choice. APPNAME is part of the URL, like https://APPNAME.herokuapp.com, so choose a name that’s unique and easy to remember.
heroku create APPNAME
Create a secret key for your application:
heroku config:set APP_SECRET=$(openssl rand -base64 32)
Enable the PostgreSQL plugin for the newly created app:
heroku addons:create heroku-postgresql -a APPNAME
Deploy the production-optimized JAR file you created in the previous section.
heroku deploy:jar target/fusioncrmtutorial-1.0-SNAPSHOT.jar -a APPNAME
Open the application in your browser.
heroku open
View the application logs and see if anything goes wrong.
heroku logs --tail
Next Steps and Helpful Links
Congratulations on finishing the tutorial!
The following are some helpful links for you to continue your learning:
If you get stuck or need help, please reach out to the Hilla Community on Discord.