Hilla Documentation

Creating, Updating, and Deleting Contacts (CRUD)

Learn how to handle create, read, update, and delete operations in a Hilla view based on LitElement and MobX.

The form component can now load and display contacts in a form, but it is still missing a key feature: saving changes.

This chapter covers:

  • saving contacts to the server,

  • deleting contacts,

  • error handling,

  • showing success and error messages.

Implementing Save and Delete Actions in the Store

The MobX CrmStore contains the state of the domain objects. All save and delete operations are handled by the store. When the store updates its internal state, the views automatically reflect the updated state.

The save and delete operations happen in two stages: First, the store calls the endpoint to update the backend. Next, if the backend operation succeeds, it updates the local state that the views use.

Saving and Deleting on the Server

Add two new actions to crm-store.ts:

async saveContact(contact: Contact) {
  try {
    const saved = await CrmEndpoint.saveContact(contact);
    if (saved) {
      this.saveLocal(saved);
    } else {
      console.log('Contact save failed');
    }
  } catch (e) {
    console.log('Contact save failed');
  }
}

async deleteContact(contact: Contact) {
  if (!contact.id) return;

  try {
    await CrmEndpoint.deleteContact(contact.id);
    this.deleteLocal(contact);
  } catch (e) {
    console.log('Contact delete failed');
  }
}

The actions call the endpoint in a try/catch block. If all goes well, they call another action to update or delete the contact locally. You implement these actions next. If something goes wrong, the actions log the error to the console and do not update the state.

Tip
Latency compensation

You can make your application faster by using latency compensation. Essentially, this means that you update the local state before calling the server, and revert the change if the server call fails.

Assuming that the server calls succeed most of the time, this makes updates appear instantaneous, even if the user is on a high-latency network or the server is slow.

Saving and Deleting Locally

Next, add two private actions to update the state in the store.

private saveLocal(saved: Contact) {
 const contactExists = this.contacts.some((c) => c.id === saved.id);
 if (contactExists) {
   this.contacts = this.contacts.map((existing) => {
     if(existing.id === saved.id) {
       return saved;
     } else {
       return existing;
     }
   });
 } else {
   this.contacts.push(saved);
 }
}

private deleteLocal(contact: Contact) {
 this.contacts = this.contacts.filter((c) => c.id !== contact.id);
}

The saveLocal() method checks if the store already contains the id of the contact. If so, it creates a new array by using the map operator to swap out the changed contact. If the contact is new, it is pushed onto the end of the contacts array.

The deleteLocal() method updates the contacts array by filtering out any contacts that match the ID of the provided contact.

Next, add save and delete actions to the list view store. The list view store knows about the selected contact and maintains the view state. This allows you to update the view state appropriately after saving or deleting a contact. Internally, the action calls the actions defined on the CrmStore.

async save(contact: Contact) {
 await crmStore.saveContact(contact);
 this.cancelEdit();
}

async delete() {
 if (this.selectedContact) {
   await crmStore.deleteContact(this.selectedContact);
   this.cancelEdit();
 }
}

Lastly, update the contact form to use the new actions. You can bind the delete action directly to the delete-button listener. Saving the form requires using the binder, so add a separate method for this.

render() {
 const { model } = this.binder;
 return html`
    <!-- omitted -->
    <div class="flex gap-s">
      <vaadin-button theme="primary" @click=${this.save}>
        ${this.binder.value.id ? 'Save' : 'Create'}
      </vaadin-button>
      <vaadin-button theme="error" @click=${listViewStore.delete}>
        Delete
      </vaadin-button>
      <vaadin-button theme="tertiary" @click=${listViewStore.cancelEdit}>
        Cancel
      </vaadin-button>
    </div>
 `;
}

async save() {
 await this.binder.submitTo(listViewStore.save);
 this.binder.clear();
}

The save button calls the save() method, which uses the binder to submit the value to the action on listViewStore. The submitTo() method validates the form, and submits it only if all values pass the validation rules.

The save-button caption uses binder.value.id to change the caption to "Save" if you are editing an existing contact, or "Create" if you are editing a new contact.

Showing Success and Error Messages

So far, errors are only logged to the console – not a convenient place for non-developers. Improve the situation by adding a way of displaying notifications.

Up to now, you have only had one store, the CrmStore containing the domain state. Messages are part of the application UI state, so create a new store to manage this.

Create a new file, frontend/stores/ui-store.ts, with the following content:

import { makeAutoObservable } from 'mobx';

class Message {
  constructor(public text = '', public error = false, public open = false) {}
}

export class UiStore {
  message = new Message();

  constructor() {
    makeAutoObservable(this, {}, { autoBind: true });
  }

  showSuccess(message: string) {
    this.showMessage(message, false);
  }

  showError(message: string) {
    this.showMessage(message, true);
  }

  clearMessage() {
    this.message = new Message();
  }

  private showMessage(text: string, error: boolean) {
    this.message = new Message(text, error, true);
    setTimeout(() => this.clearMessage(), 5000);
  }
}

The store has two public actions: showSuccess() and showError(). Internally, both use the showMessage() action to update the message property. The action includes a 5-second timeout, after which it resets the message state.

Next, add the new store to the AppStore and export it. This ensures you only have one UiStore.

import { CrmStore } from './crm-store';
import { UiStore } from './ui-store';

export class AppStore {
  crmStore = new CrmStore();
  uiStore = new UiStore();
}

export const appStore = new AppStore();
export const crmStore = appStore.crmStore;
export const uiStore = appStore.uiStore;

Next, add a notification component to the list view.

Add the import:

import '@vaadin/notification';
import { uiStore } from 'Frontend/stores/app-store';

Then, at the end of the template, in the render() method, add a notification component that is bound to the message state.

<vaadin-notification
  theme=${uiStore.message.error ? 'error' : 'contrast'}
  position="bottom-start"
  .opened=${uiStore.message.open}
  .renderer=${(root: HTMLElement) =>
    (root.textContent = uiStore.message.text)}>
</vaadin-notification>

The notification component uses a renderer method to define content. The method receives the notification root HTML element as input and you can render any content into it. In this case, we just add the message text. Renderers are also used by Grid to customize cell contents.

Now that you have a flexible way of showing messages, put it to use in the CrmStore. Remember to import uiStore.

async saveContact(contact: Contact) {
  try {
    const saved = await CrmEndpoint.saveContact(contact);
    if (saved) {
      this.saveLocal(saved);
      uiStore.showSuccess('Contact saved.');
    } else {
      uiStore.showError('Contact save failed.');
    }
  } catch (e) {
    console.log(e);
    uiStore.showError('Contact save failed.');
  }
}

async deleteContact(contact: Contact) {
  if (!contact.id) return;

  try {
    await CrmEndpoint.deleteContact(contact.id);
    this.deleteLocal(contact);
    uiStore.showSuccess('Contact deleted.');
  } catch (e) {
    uiStore.showError('Failed to delete contact.');
  }
}

In your browser, verify that you can save and delete contacts. Refresh your browser to see that the changes are persisted in the database. You should see a notification in the bottom-left corner when saving or deleting a contact.