Docs

Basics tutorial

Learn the basics of Hilla application development: creating a project, defining and accessing endpoints, and building views.

1. What you need

  • About 10–20 minutes, depending on if you decide to do every part step by step.

  • Node 16.14 or later.

  • JDK 11 or later, 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, TypeScript, HTML, and CSS.

This tutorial is intended for developers with a basic understanding of Java, JavaScript, React, HTML, and CSS. You don’t need to be an expert by any means, but understanding the syntax and basic concepts makes it easier to follow along.

2. Create a Hilla project

Use the Hilla CLI to create a new project:

npx @hilla/cli init --preset react-tutorial hilla-todo

Alternatively, you can download the starter as a zip-file and extract it.

Unpack the downloaded zip into a folder on your computer, and import the project in the IDE of your choice.

If you open one of the .ts files inside the frontend/views folder, you might see red underlines for unresolved imports. They get resolved when you start the development server for the first time, when all dependencies are downloaded.

Importing into VS Code

Import the project by either:

  • navigating to the project folder and running code . (note the period), or

  • choosing File  Open…​ in VS Code and selecting the project folder.

You should install the following extensions to VS Code for an optimal development experience:

VS Code should automatically suggest these for you when you open the project.

Project architecture and structure

Hilla projects are based on Spring Boot and use Maven for dependency management and build configuration.

Project structure

The following lists the key folders and files in a Hilla application project:

frontend

The folder where your views and front-end code live.

src

The folder where your Java back-end code lives.

pom.xml

The project configuration file, which defines dependencies.

frontend/index.html

The bootstrap page. You don’t usually need to edit this.

frontend/routes.tsx

Your application routes are defined here.

src/main/java/com/example/application/Application.java

The Java class which runs the Spring Boot application.

3. Define 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:

  1. Define an entity.

  2. 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 the pom.xml file.

3.1. Define an entity

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;
  }
}
  1. Turn the class into a JPA entity with an @Entity annotation.

  2. Add a @NotBlank Java bean validation annotation to enforce validity both in the view and on the server.

3.2. Create a repository

Next, create a repository for accessing the database. You only need to define 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 back-end code in place to start building a UI.

Hilla can generate TypeScript versions of the Java files you created. To achieve this, run the project from the command line:

.\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 front-end bundle. Subsequent builds don’t download dependencies, so that they are much faster.

When the build has finished, you should see the application running on http://localhost:8080.

Running project

4. 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 breaking the UI when you change the data model on the server.

Create a new TodoEndpoint.java file in src/main/java/com/example/application with the following content:

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);
  }
}
  1. Annotating a class with @Endpoint exposes it as a service for client-side views. All public methods of an endpoint are callable from TypeScript.

  2. By default, endpoint access requires an authenticated user. @AnonymousAllowed enables access for anyone. See Configuring Security for more information on endpoint security.

  3. Use Spring to automatically inject the TodoRepository dependency for database access.

  4. Using the @Nonnull annotation ensures that the TypeScript generator doesn’t interpret these values as possibly undefined.

5. Build the Todo view

Hilla can use React for client-side code. You can use your React skills and favourite libraries, and you have first-class support for Vaadin components.

Next, you create a view for adding and viewing to-do items. You can choose to build it step by step, learning some concepts along the way, or to copy the complete view implementation if you are in a hurry. When you’re ready, you can learn more about creating components.

In this example we’ll use Formik to create our form, so we need to add it to the project:

npm install formik --save
Build the view step by step

Open the frontend/views/todo/TodoView.tsx file and replace its contents with the following:

import type Todo from 'Frontend/generated/com/example/application/Todo'; // (1)
import { useEffect, useState } from 'react';
import { FormikErrors, useFormik } from 'formik';
import { Button } from '@hilla/react-components/Button.js';
import { Checkbox } from '@hilla/react-components/Checkbox.js';
import { TextField } from '@hilla/react-components/TextField.js';
import { TodoEndpoint } from 'Frontend/generated/endpoints';
import { EndpointValidationError } from '@hilla/frontend';

export default function TodoView() { // (2)
  return (
  );
}
  1. Import the UI components, helpers, and generated TypeScript models required for building the view.

  2. Define the view.

Define the view state

Inside the TodoView class, define the view state as follows:

const empty: Todo = { task: '', done: false };
const [todos, setTodos] = useState(Array<Todo>()); // (1)
  1. The list of Todo items is stored using a React Hook.

Create the form handler

const formik = useFormik({
  initialValues: empty,
  onSubmit: async (value: Todo, { setSubmitting, setErrors }) => {
    try {
      const saved = await TodoEndpoint.save(value) ?? value;
      setTodos([...todos, saved]);
      formik.resetForm();
    } catch (e: unknown) {
      if (e instanceof EndpointValidationError) {
        const errors: FormikErrors<Todo> = {}
        for (const error of e.validationErrorData) {
          if (typeof error.parameterName === 'string' && error.parameterName in empty) {
            const key = error.parameterName as (string & keyof Todo);
            errors[key] = error.message;
          }
        }
        setErrors(errors);
      }
    } finally {
      setSubmitting(false);
    }
  },
});

Define the HTML template

Add the following code inside the return statement:

<>
  <div className="m-m flex items-baseline gap-m">
    <TextField
      name='task'
      label="Task"
      value={formik.values.task}
      onChange={formik.handleChange}
      onBlur={formik.handleChange}
    />
    <Button
      theme="primary"
      disabled={formik.isSubmitting}
      onClick={formik.submitForm}
    >Add</Button>
  </div>
</>

Right below the previous <div>, add the following code:

<div className="m-m flex flex-col items-stretch gap-s">
  {todos.map(todo => (
    <Checkbox
      key={todo.id}
      checked={todo.done}
      onCheckedChanged={({ detail: { value } }) => changeStatus(todo, value)}>
      {todo.task}
    </Checkbox>
  ))}
</div>

Before the return method, add a React effect to load the initial values.

useEffect(() => {
  (async () => {
    setTodos(await TodoEndpoint.findAll());
  })();

  return () => { };
}, []);

Finally, add a function to handle checking a todo as done.

async function changeStatus(todo: Todo, done: boolean) {
  const newTodo = { ...todo, done: done };
  const saved = await TodoEndpoint.save(newTodo) ?? newTodo;
  setTodos(todos.map(item => item.id === todo.id ? saved : item));
}
Get the complete view implementation

Open the frontend/views/todo/TodoView.tsx file and replace its contents with the following:

import type Todo from 'Frontend/generated/com/example/application/Todo';
import { useEffect, useState } from 'react';
import { FormikErrors, useFormik } from 'formik';
import { Button } from '@hilla/react-components/Button.js';
import { Checkbox } from '@hilla/react-components/Checkbox.js';
import { TextField } from '@hilla/react-components/TextField.js';
import { TodoEndpoint } from 'Frontend/generated/endpoints';
import { EndpointValidationError } from '@hilla/frontend';

export default function TodoView() {
  const empty: Todo = { task: '', done: false };
  const [todos, setTodos] = useState(Array<Todo>());

  useEffect(() => {
    (async () => {
      setTodos(await TodoEndpoint.findAll());
    })();

    return () => { };
  }, []);

  const formik = useFormik({
    initialValues: empty,
    onSubmit: async (value: Todo, { setSubmitting, setErrors }) => {
      try {
        const saved = await TodoEndpoint.save(value) ?? value;
        setTodos([...todos, saved]);
        formik.resetForm();
      } catch (e: unknown) {
        if (e instanceof EndpointValidationError) {
          const errors: FormikErrors<Todo> = {}
          for (const error of e.validationErrorData) {
            if (typeof error.parameterName === 'string' && error.parameterName in empty) {
              const key = error.parameterName as (string & keyof Todo);
              errors[key] = error.message;
            }
          }
          setErrors(errors);
        }
      } finally {
        setSubmitting(false);
      }
    },
  });

async function changeStatus(todo: Todo, done: boolean) {
  const newTodo = { ...todo, done: done };
  const saved = await TodoEndpoint.save(newTodo) ?? newTodo;
  setTodos(todos.map(item => item.id === todo.id ? saved : item));
}

  return (
    <>
      <div className="m-m flex items-baseline gap-m">
        <TextField
          name='task'
          label="Task"
          value={formik.values.task}
          onChange={formik.handleChange}
          onBlur={formik.handleChange}
        />
        <Button
          theme="primary"
          disabled={formik.isSubmitting}
          onClick={formik.submitForm}
        >Add</Button>
      </div>

      <div className="m-m flex flex-col items-stretch gap-s">
        {todos.map(todo => (
          <Checkbox
            key={todo.id}
            checked={todo.done}
            onCheckedChanged={({ detail: { value } }) => changeStatus(todo, value)}>
            {todo.task}
          </Checkbox>
        ))}
      </div>
    </>
  );
}

6. Run the application

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 front-end bundle. Subsequent builds don’t download dependencies, so that they are much faster.

When the build has finished, you should see the application running on http://localhost:8080.

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.

The to-do application running in a web browser