DEV Community

Cover image for Implementing LiveViews in Node.js
Megan Lee for LogRocket

Posted on • Originally published at blog.logrocket.com

Implementing LiveViews in Node.js

Written by Emmanuel John✏️

LiveViewJS is an open source, full-stack framework for building LiveView-based, full-stack web applications in Node.js and Deno with ease.

LiveViewJS leverages the power of WebSockets and server-side rendering to enable real-time updates and seamless user experiences like React, Vue, and more, but with far less code and complexity and far more speed and efficiency.

In this article, we'll compare the LiveView model to SPAs, demonstrate how to implement LiveViews in Node.js, and discuss use cases for LiveViews in frontend applications.

Prerequisites

To follow along with this tutorial, you should have the following:

  • Node.js version 18.x or above
  • Familiarity with TypeScript

Types of web applications

Decades ago, we had fewer solutions for building web applications. But today, there are many solutions and we can categorize them as follows:

  • Traditional server-side rendered app
  • Traditional SPA
  • Universal SSR app
  • Static-generated app

Now, we also have the LiveView paradigm as the most recent solution for building full-stack web applications.

Traditional single-page apps (SPAs)

SPAs are client-side rendered (CSR) web applications that use JavaScript to render contents in the browser without requiring a browser reload for every new page. So, rather than receiving the contents rendered to the HTML document, you receive barebones HTML from the server, and JavaScript will be used to load the content in the browser.

The advantages of SPAs are rich user experience and easier development and deployment. Their downsides include slow initial load speed and poor performance on the search engine.

Understanding LiveView

In the LiveView model, the server renders an HTML page when a user makes the initial HTTP request, and then a persistent WebSocket connection connects the page to the server, after which user-initiated events such as clicks, key events, form input, etc., are sent to the server in very small packets via the WebSocket.

The moment the server receives the user-initiated events, it runs the business logic for that LiveView, calculates the newly rendered HTML, and then sends only the diffs to the client, which automatically updates the page with the diffs.

LiveView vs. SPAs

If you've used any of these SPA frameworks (React, Vue, and Svelte) to build production-ready applications, you'll notice that you need a completely different backend to handle business logic and data persistence — typically a REST or GRAPHQL API — while you handle the state management and rendering on the client.

While this is great, you'll need to write two code bases for both the frontend and backend and manage the communication between these two codebases. But LiveViewJS offers a single codebase that handles both the frontend and backend while enabling the same rich user experiences that SPAs enable and allows you to build real-time interactive applications at ease without integrating any third-party library.

LiveViewJS greatly simplifies the full-stack web application development process, reduces the number of moving parts, and increases development speed.

As you follow along with this tutorial, you'll see how easy it is to build rich, interactive, and responsive user experiences with LiveViewJS and gain a better understanding of how much of an improvement it is.

Exploring LiveViews in Node.js

LiveViewJS is highly compatible with Node.js and its ecosystem, offering developers a powerful and flexible platform for building real-time web applications. Let's explore LiveViewjs APIs for more information.

LiveViewSocket API

The LiveViewSocket API is one of the most important APIs in LiveView. This API consists of the following functions:

  • socket.assign
  • socket.context
  • socket.pushEvent
  • Server events

The socket.assign method and socket.context property are used to modify and read the state of the LiveView, respectively.

The following code snippet modifies the context (current state) of the LiveView:

socket.assign({ foo: "bar" });
Enter fullscreen mode Exit fullscreen mode

This reads the context (current state) of the LiveView:

socket.context.foo
Enter fullscreen mode Exit fullscreen mode

The socket.pushEvent method enables the server to “push” data to the client and update the URL of the LiveView:

socket.pushEvent({
  type: "my-event",
  foo: "bar",
});
Enter fullscreen mode Exit fullscreen mode

Now, the client can listen to this event in two ways.

Using window.addEventListener:

window.addEventListener("phx:my-event", (event) => {
  console.log(event.detail.foo);
});
Enter fullscreen mode Exit fullscreen mode

Using a client hook:

this.handleEvent("my-event", (event) => {
  console.log(event.foo); 
});
Enter fullscreen mode Exit fullscreen mode

LiveViewSocket API: Server events

In a situation where a LiveView may need to wait for a long database query or search service to complete before rendering the results or sending updates based on a webhook or action from another user, server events are necessary. These server events help manage asynchronous processes.

socket.sendInfo enables a LiveView to send messages:

socket.sendInfo({ type: "run_search", query: event.query })
Enter fullscreen mode Exit fullscreen mode

socket.subscribe enables a LiveView to subscribe to a topic using pub/sub. This is useful for cases where a LiveView needs to receive updates from another process or user:

socket.subscribe("branches")
Enter fullscreen mode Exit fullscreen mode

User events

There are four main types of user events that a LiveView can listen and respond to:

  • Click events
  • Form events
  • Key events
  • Focus events

To listen for user events, there are a set of attributes that you need to add to the HTML elements in your LiveView render method.

Value bindings

When you need to send additional data with an event binding, you can use a value binding, which looks something like phx-value-[NAME] where [NAME] is replaced by the key of the value you want to pass.

For example, let's say you want to send the mark_complete event to the server along with an id value (e.g., {id: "myId"}) when the user clicks on the Complete button. To do this, you’d do the following:

<button phx-click="mark_complete" phx-value-id="myId">Complete</button>
Enter fullscreen mode Exit fullscreen mode

This example would send the following event to the server:

{
  type: "mark_complete",
  id: "myId"
}
Enter fullscreen mode Exit fullscreen mode

Implementing LiveViews in Node.js

In this section, we'll build a full-stack Node.js application using LiveViewJS. The application will be a bank management application where a bank can set up multiple branches in different locations for its operations, update each branch's details, disable branches, and delete branches.

Here is what the final application will look like: Final Bank Application

Creating a new project

LiveViewJS has a project generation tool that will set up the project structure and install the required dependencies for either Node or Deno.

First, run the following command:

npx @liveviewjs/gen
Enter fullscreen mode Exit fullscreen mode

You will be prompted to select the type of project you want to create and asked a few other questions. Then, voilà, you will have a new project that runs out of the box!

Navigate to src/server/liveTemplates.ts and replace the default Tailwind script with your own CSS:

<head>
  ...
  <script src="https://cdn.tailwindcss.com"></script>
</head>
Enter fullscreen mode Exit fullscreen mode

We'll use Tailwind CSS to style the application.

Creating the inMemory data store

To persist data within the application, we will set up an in-memory implementation of a database that works with changesets and pub/sub.

Navigate to the src folder and create a dataStore/inMemory.ts file with the following:

import { LiveViewChangeset, LiveViewChangesetFactory, newChangesetFactory, PubSub } from "liveviewjs";
import { SomeZodObject } from "zod";

type InMemoryChangesetDBOptions = {
  pubSub?: PubSub;
  pubSubTopic?: string;
};
Enter fullscreen mode Exit fullscreen mode

Here, we’ll import all the necessary modules and types from the LiveViewJS and Zod libraries.

Then, we’ll define a type InMemoryChangesetDBOptions, which is an object type with optional properties used to configure the in-memory database.

Next, we'll define a InMemoryChangesetDB class, which serves as an in-memory database:

export class InMemoryChangesetDB<T> {
  #store: Record<string, T> = {};
  #changeset: LiveViewChangesetFactory<T>;
  #pubSub?: PubSub;
  #pubSubTopic?: string;
}
Enter fullscreen mode Exit fullscreen mode

We'll start by defining the following private properties:

  • #store: This property holds the data in a key-value pair format, where the key is a string representing the unique identifier of each data item and the value is of type T
  • #changeset: This property holds the factory function for creating changesets. It's used to create changesets for validating and modifying data
  • **#pubSuband#pubSubTopicB: These properties are optional and are used for publishing and subscribing to events. They are used for pub/sub communication to broadcast changes to subscribers

Next, we'll define the class constructor, which takes a schema of type SomeZodObject and an optional options object of type InMemoryChangesetDBOptions:

export class InMemoryChangesetDB<T> {
  ...
  constructor(schema: SomeZodObject, options?: InMemoryChangesetDBOptions) {
    this.#changeset = newChangesetFactory(schema);
    this.#pubSub = options?.pubSub;
    this.#pubSubTopic = options?.pubSubTopic;
  }
}
Enter fullscreen mode Exit fullscreen mode

The constructor initializes the #changeset property with a new changeset factory created from the provided schema. It also assigns the #pubSub and #pubSubTopic properties based on the provided options.

Next, we'll define the changeset method, which creates and returns a new changeset:

export class InMemoryChangesetDB<T> {
  ...
  changeset(existing?: Partial<T>, newAttrs?: Partial<T>, action?: string): LiveViewChangeset<T> {
    return this.#changeset(existing ?? {}, newAttrs ?? {}, action);
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll create the data access methods to retrieve data from our data store:

export class InMemoryChangesetDB<T> {
  ...  
  list(): T[] {
    return Object.values(this.#store);
  }

  get(id: string): T | undefined {
    return this.#store[id];
  }
}
Enter fullscreen mode Exit fullscreen mode

The list method returns an array containing all the values stored in the database while the get method retrieves the value corresponding to the provided ID from the database.

Next, create a validate method. This method creates a changeset with the provided data for validation purposes:

export class InMemoryChangesetDB<T> {
  ...
  validate(data: Partial<T>): LiveViewChangeset<T> {
    return this.changeset({}, data, "validate");
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we'll define methods for CRUD operations:

export class InMemoryChangesetDB<T> {
  ...
  create(data: Partial<T>): LiveViewChangeset<T> {
    const result = this.#changeset({}, data, "create");
    if (result.valid) {
      const newObj = result.data as T;
      // assume there will be an id field
      this.#store[(newObj as any).id] = newObj;
      this.broadcast("created", newObj);
    }
    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

The method above creates a new data item in the database. It first creates a changeset, checks if it's valid, adds the new data to the store, and broadcasts a created event using pub/sub.

The following method updates an existing data item in the database:

export class InMemoryChangesetDB<T> {
  ...
  update(current: T, data: Partial<T>): LiveViewChangeset<T> {
    const result = this.#changeset(current, data, "update");
    if (result.valid) {
      const newObj = result.data as T;
      this.#store[(newObj as any).id] = newObj;
      this.broadcast("updated", newObj);
    }
    return result;
  }
}
Enter fullscreen mode Exit fullscreen mode

It creates a changeset, checks if it's valid, updates the data in the store, and broadcasts an updated event using pub/sub.

Finally, this method deletes a data item from the database based on its id:

export class InMemoryChangesetDB<T> {
  ... 
  delete(id: string): boolean {
    const data = this.#store[id];
    const deleted = data !== undefined;
    if (deleted) {
      delete this.#store[id];
      this.broadcast("deleted", data);
    }
    return deleted;
  }
}
Enter fullscreen mode Exit fullscreen mode

It removes the item from the store and broadcasts a deleted event using pub/sub.

The private broadcast method is responsible for broadcasting events using pub/sub:

export class InMemoryChangesetDB<T> {
  ...
  private async broadcast(type: string, data: T) {
    if (this.#pubSub && this.#pubSubTopic) {
      await this.#pubSub.broadcast(this.#pubSubTopic, { type, data });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It checks if the pub/sub and the topic are defined, then broadcasts the provided type and data to subscribers.

Building the CRUD features of the application

Create a new src/server/liveview/bank.ts file. This is where all application logic will live.

We'll start by importing specific functions and objects from the Nano ID and Zod libraries:

import {
  createLiveView,
  error_tag,
  form_for,
  html,
  LiveViewChangeset,
  newChangesetFactory,
  SingleProcessPubSub,
  submit,
  text_input,
} from "liveviewjs";
import { nanoid } from "nanoid";
import { z } from "zod";
Enter fullscreen mode Exit fullscreen mode

Schema setup

Next, we'll set up a Zod schema for a branch object, ensuring that any data conforming to this schema satisfies certain validation criteria:

const BranchSchema = z.object({
  id: z.string().default(nanoid),
  name: z.string().min(2).max(100),
  manager: z.string().min(4).max(100),
  address: z.string().min(4).max(100),
  contact: z.string().min(4).max(100),
  status: z.boolean().default(false),
});
Enter fullscreen mode Exit fullscreen mode

Next, we'll infer the Branch type from the BranchSchema:

type Branch = z.infer<typeof BranchSchema>;
Enter fullscreen mode Exit fullscreen mode

Then, add the following to create the branch LiveViewChangesetFactory:

const branchCSF = newChangesetFactory<Branch>(BranchSchema);
Enter fullscreen mode Exit fullscreen mode

branchCSF creates a factory for producing LiveViewChangeset instances tailored to the Branch type.

Add the following to create an in-memory data store for Branch types:

const branchesDB: Record<string, Branch> = {};
Enter fullscreen mode Exit fullscreen mode

Add the following to create a pub/sub for publishing changes:

const pubSub = new SingleProcessPubSub();
Enter fullscreen mode Exit fullscreen mode

pubSub allows components of the application to subscribe to changes made to branches and publish updates when branches are modified.

Declare the variable editBranchId outside the scope of the LiveView component to keep track of the ID of the branch currently being edited:

let editBranchId = "";
Enter fullscreen mode Exit fullscreen mode

Next, define the branchesLiveView component:

export const branchesLiveView = createLiveView<
  // Define the Context of the LiveView
  {
    branches: Branch[];
    changeset: LiveViewChangeset<Branch>;
    editBranchId: string | null;
  },
  // Define events that are initiated by the end-user
  | { type: "save"; name: string; manager: string }
  | { type: "validate"; name: string; manager: string, address: string, contact: string }
  | { type: "toggle-status"; id: string }
  | { type: "edit"; id: string }
  | { type: "update"; id: string }
  | { type: "delete"; id: string }
>({
  mount: (socket) => {  },
  handleEvent: (event, socket) => {  },
  handleInfo: (info, socket) => {  },
  render: (context, meta) => {  }
})
Enter fullscreen mode Exit fullscreen mode

This declares a LiveView component named branchesLiveView using the createLiveView function. It defines the context of the LiveView and events that are initiated by the end user.

Update the mount method with the following:

mount: (socket) => {
  if (socket.connected) {
    socket.subscribe("branches");
  }
  socket.assign({
    branches: Object.values(branchesDB),
    changeset: branchCSF({}, {}),
    editBranchId: null,
  });
}
Enter fullscreen mode Exit fullscreen mode

The mount function is called when the LiveView is mounted onto the DOM. Here, we ensure that LiveView is subscribed to the branches channel if the socket connection is established, then initialize the LiveView's context with initial values, including the current list of branches, an empty changeset, and a null value for the editBranchId property.

Update the handleInfo method with the following:

handleInfo: (info, socket) => {
  if (info.type === "updated") {
    socket.assign({
      branches: Object.values(branchesDB),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

handleInfo handles info messages received by the LiveView component, specifically those with the type updated. When such a message is received, it updates the LiveView context with the most recent list of branches, ensuring that the UI reflects the latest changes.

Form validation

Form validation is an essential part of application development as it contributes to enhancing the security of the application.

Update the handleEvent method with the following:

handleEvent: (event, socket) => {
  switch (event.type) {
    case "validate":
      socket.assign({
        changeset: branchCSF({}, event, "validate"),
      });
      break;
  }
},
Enter fullscreen mode Exit fullscreen mode

The handleEvent method is called when an event is triggered within the LiveView component. It processes different types of events and performs corresponding actions based on the event type. The validate case validates the form data.

Next, update the render method as follows:

render: (context, meta) => {
    const { changeset, branches, editBranchId } = context;
    const { csrfToken } = meta;
    return html`
      <h1 class="text-green-400 text-3xl mb-6 text-center">Cosmos Bank</h1>

      <div class="flex w-full justify-center">
        <div id="branchForm" class="bg-slate-100 w-[25rem] mb-8 rounded-xl p-8 bg-white">
          ${form_for<Branch>("#", csrfToken, {
      phx_change: "validate"
    })}

          <div class="space-y-4">
            <div>
              ${text_input(changeset, "name", { placeholder: "Branch Name", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
              ${error_tag(changeset, "name", { className: "text-red-500 text-sm" })}
            </div>
            <div>
              ${text_input(changeset, "manager", { placeholder: "Manager", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
              ${error_tag(changeset, "manager", { className: "text-red-500 text-sm" })}
            </div>
            <div>
              ${text_input(changeset, "address", { placeholder: "Address", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
              ${error_tag(changeset, "address", { className: "text-red-500 text-sm" })}
            </div>
            <div>
              ${text_input(changeset, "contact", { placeholder: "Contact", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
              ${error_tag(changeset, "contact", { className: "text-red-500 text-sm" })}
            </div>
          </div>
          <button class="flex justify-center bg-blue-700 mt-8 p-2 text-white w-full rounded-md">
            submit
          </button>
          </form>
        </div>
      </div>
    `;
  },
Enter fullscreen mode Exit fullscreen mode

The render function takes two parameters: context and meta and returns HTML content using template literals (html). context contains the current state of the LiveView, while meta contains metadata such as csrfToken, which is used for security purposes.

The form_for helper function dynamically generates a form, while the text_input helper function generates the input fields. The input fields are bound to the changeset object, allowing for real-time validation and updating of form data. The error_tag helper function displays error messages for each input field if validation fails. The phx_change: "validate" and phx_debounce: 1000 trigger the validate case one second after the input change value changes.

If you followed up to this point of the tutorial, you should have the following result: Input Field Error

Implementing the create feature

To add a new branch to our data store, we’ll implement the create feature.

First, add the save case to the switch:

handleEvent: (event, socket) => {
  switch (event.type) {
    ...
    case "save":
      // attempt to create the branch from the form data
      const saveChangeset = branchCSF({}, event, "save");
      let changeset = saveChangeset;
      if (saveChangeset.valid) {
        // save the branch to the in memory data store
        const newBranch = saveChangeset.data as Branch;
        branchesDB[newBranch.id] = newBranch;
        // since branch was saved, reset the changeset to empty
        changeset = branchCSF({}, {});
      }
      // update context
      socket.assign({
        branches: Object.values(branchesDB),
        changeset,
      });
      pubSub.broadcast("branches", { type: "updated" });
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The save case executes when the event type is save and handles the save event by validating form data, saving a new branch to the data store if the data is valid, resetting the changeset to empty, and updating the LiveView context with the changes. Finally, it broadcasts an update event to notify other components about the changes.

Now, update the form submission button as follows:

<div class="flex justify-center bg-blue-700 mt-8 p-2 text-white w-full rounded-md">
  ${submit("Add Branch", { phx_disable_with: "Saving..." })}
</div>
Enter fullscreen mode Exit fullscreen mode

Then add the phx_submit: "save" to the form_for helper function as follows:

${form_for<Branch>("#", csrfToken, {
  phx_submit: "save",
  phx_change: "validate",
})}
Enter fullscreen mode Exit fullscreen mode

The modified form component should look like the following:

return html`
  <h1 class="text-green-400 text-3xl mb-6 text-center">Cosmos Bank</h1>

  <div class="flex w-full justify-center">
    <div id="branchForm" class="bg-slate-100 w-[25rem] mb-8 rounded-xl p-8 bg-white">
      ${form_for<Branch>("#", csrfToken, {
  phx_submit: "save",
  phx_change: "validate",
})}

      <div class="space-y-4">
        <div>
          ${text_input(changeset, "name", { placeholder: "Branch Name", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
          ${error_tag(changeset, "name", { className: "text-red-500 text-sm" })}
        </div>
        <div>
          ${text_input(changeset, "manager", { placeholder: "Manager", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
          ${error_tag(changeset, "manager", { className: "text-red-500 text-sm" })}
        </div>
        <div>
          ${text_input(changeset, "address", { placeholder: "Address", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
          ${error_tag(changeset, "address", { className: "text-red-500 text-sm" })}
        </div>
        <div>
          ${text_input(changeset, "contact", { placeholder: "Contact", className: "w-full h-8 p-2 bg-gray-100", autocomplete: "off", phx_debounce: 1000 })}
          ${error_tag(changeset, "contact", { className: "text-red-500 text-sm" })}
        </div>
      </div>
      <div class="flex justify-center bg-blue-700 mt-8 p-2 text-white w-full rounded-md">
        ${submit("Add Branch", { phx_disable_with: "Saving..." })}
      </div>
      </form>
    </div>
  </div> 
`;
Enter fullscreen mode Exit fullscreen mode

Implementing the read feature

To view the list of created branches, add the list component just before the closing HTML backtick as follows:

return html`
  //Form
  ...
  <div id="branches" class="flex flex-wrap space-x-4 items-center justify-center">
    ${branches.map((branch) => renderBranch(branch, csrfToken, editBranchId))}
  </div>
`;
Enter fullscreen mode Exit fullscreen mode

The branches array from context is mapped over, and each branch is rendered using the renderBranch function.

Then, create the renderBranch component as follows:

function renderBranch(b: Branch, csrfToken: any, editBranchId: string | null) {
  return html`
    <figure id="${b.id}" class="flex bg-slate-100 w-[30rem] mt-4 rounded-xl p-8 md:p-0 bg-white">
        <img class="w-24 h-24 md:w-48 md:h-auto  md:rounded-l-lg" src="https://media.wired.com/photos/59269cd37034dc5f91bec0f1/master/pass/GoogleMapTA.jpg" alt="" width="384" height="512">
        <div class="pt-6 md:p-8 text-center md:text-left">
          <div class="space-y-1">
            <p class="text-base font-normal">
              Branch name: ${b.name}
            </p>
            <p class="text-base font-normal">
              Address: ${b.address}
            </p>
            <p class="text-base font-normal">
              Contact: ${b.contact}
            </p>
            <p class="text-base font-normal">
              Total Staff: 24
            </p>
          </div>
          <div class="flex space-x-2 items-center mt-8">
            <img class="w-10 h-10 rounded-full" src="https://media.wired.com/photos/59269cd37034dc5f91bec0f1/master/pass/GoogleMapTA.jpg" alt=""/>
            <figcaption class="font-medium">
              <div class="text-sky-500 dark:text-sky-400">
              ${b.manager}
              </div>
              <div class="text-slate-700 dark:text-slate-500">
                Branch Manager
              </div>
            </figcaption>
          </div>
        </div>
      </figure>
  `;
}
Enter fullscreen mode Exit fullscreen mode

The renderBranch function dynamically generates HTML markup to display information about a branch, including its name, status, and manager, as well as providing options for editing.

If you have followed up to this point, you should have the following result: Bank Application With Branch Information

Implementing the update feature

To update the information for an existing branch in our web app, we’ll implement a status update using the update feature.

First, we'll implement the status update, then we’ll implement an update for the entire branch.

Add the toggle-status case to the switch:

handleEvent: (event, socket) => {
  switch (event.type) {
    ...
    case "toggle-status":
      const branch = branchesDB[event.id];
      if (branch) {
        branch.status = !branch.status;
        branchesDB[branch.id] = branch;
        socket.assign({
          branches: Object.values(branchesDB),
        });
        pubSub.broadcast("branches", { type: "updated" });
      }
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The toggle-status case executes when the event type is toggle-status, handles the toggle-status event by toggling the status of the branch specified in the event, updating the data store, LiveView context, and broadcasting the changes to other components.

Add the edit case to the switch:

handleEvent: (event, socket) => {
  switch (event.type) {
    ...
    case "edit":
      editBranchId = event.id;
      socket.assign({
        editBranchId: event.id,
      });
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The edit case executes when the user triggers an edit event. It updates the editBranchId variable and the LiveView context with the ID of the branch being edited. This allows the UI to reflect that the user is currently editing a specific branch.

Add the update case to the switch:

handleEvent: (event, socket) => {
  switch (event.type) {
    ...
    case "update":
      const editChangeset = branchCSF(branchesDB[editBranchId], event, "save");
      if (editChangeset.valid) {
        const editedBranch = editChangeset.data as Branch;
        branchesDB[editedBranch.id] = editedBranch;

        socket.assign({
          branches: Object.values(branchesDB),
          changeset: branchCSF({}, {}),
          editBranchId: null,
        });

        pubSub.broadcast("branches", { type: "updated" });
      }
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The update case executes when the user triggers an update event. It edits an existing branch with the provided data, updating the branch in the in-memory data store and the LiveView context, and broadcasting the changes to other components if the update is successful.

Next, update the renderBranch component as follows:

function renderBranch(b: Branch, csrfToken: any, editBranchId: string | null) {
  return html`
    <figure id="${b.id}" class="flex bg-slate-100 w-[30rem] mt-4 rounded-xl p-8 md:p-0 bg-white">
        <img class="w-24 h-24 md:w-48 md:h-auto  md:rounded-l-lg" src="https://media.wired.com/photos/59269cd37034dc5f91bec0f1/master/pass/GoogleMapTA.jpg" alt="" width="384" height="512">
        <div class="pt-6 md:p-8 text-center md:text-left">
          <div class="space-y-1">
            <p class="text-base font-normal">
              Branch name: ${b.name}
            </p>
            <p class="text-base font-normal">
              Address: ${b.address}
            </p>
            <p class="text-base font-normal">
              Contact: ${b.contact}
            </p>
            <p class="text-base font-normal">
              Total Staff: 24
            </p>
          </div>
          <button class="${b.status ? 'bg-red-700' : "bg-green-700"} px-4 py-1.5 rounded-md text-white" phx-click="toggle-status" phx-value-id="${b.id}" phx-disable-with="Updating...">
            ${b.status ? "Disabled" : "Activated"}
          </button>
          <div class="flex space-x-2 items-center mt-8">
            <img class="w-10 h-10 rounded-full" src="https://media.wired.com/photos/59269cd37034dc5f91bec0f1/master/pass/GoogleMapTA.jpg" alt=""/>
            <figcaption class="font-medium">
              <div class="text-sky-500 dark:text-sky-400">
              ${b.manager}
              </div>
              <div class="text-slate-700 dark:text-slate-500">
                Branch Manager
              </div>
            </figcaption>
          </div>
          ${editBranchId === b.id ? editForm(b, csrfToken) : editButton(b.id)}
        </div>
      </figure>
  `;
}
Enter fullscreen mode Exit fullscreen mode

Next, create the editButton components as follows:

function editButton(id: string) {
  return html`
    <button class="bg-blue-700 text-white text-sm py-2 px-4 rounded-md" phx-click="edit" phx-value-id="${id}">Edit</button>
  `;
}
Enter fullscreen mode Exit fullscreen mode

The editButton function generates HTML markup for rendering buttons related to editing a branch, with dynamic IDs and event-handling attributes.

Next, we’ll create the editForm components as follows. The editForm function generates HTML markup for rendering an editable form to update branch information, with input fields for each attribute and a submit button to trigger the update action:

function editForm(branch: Branch, csrfToken: any) {
  return html`
    ${form_for<Branch>("#", csrfToken, {
    phx_submit: "update",
  })}
      <div class="space-y-2">
        <div class="field">
          ${text_input(branchCSF({}, branch), "name", { placeholder: "name", autocomplete: "off", className: "w-full border h-8 p-2 bg-gray-100" })}
        </div>
        <div class="field">
          ${text_input(branchCSF({}, branch), "manager", { placeholder: "manager", autocomplete: "off", className: "w-full border h-8 p-2 bg-gray-100" })}
        </div>
        <div class="field">
          ${text_input(branchCSF({}, branch), "address", { placeholder: "address", autocomplete: "off", className: "w-full border h-8 p-2 bg-gray-100" })}
        </div>
        <div class="field">
          ${text_input(branchCSF({}, branch), "contact", { placeholder: "contact", autocomplete: "off", className: "w-full border h-8 p-2 bg-gray-100" })}
        </div>
        <div class="flex justify-center bg-blue-700 mt-8 p-2 text-white w-full rounded-md">
          ${submit("Update Branch", { phx_disable_with: "Updating...", phx_value_id: branch.id })}
        </div>
      </div>
    </form>
  `;
}
Enter fullscreen mode Exit fullscreen mode

Users can now toggle between disabled and activated for the status of each branch as well as update the branch details: Displaying Disabled Branch Information

Implementing the delete feature

To delete branches from our web application, we’ll add the delete case to the switch:

handleEvent: (event, socket) => {
  switch (event.type) {
    ...
    case "delete":
      delete branchesDB[event.id];
      socket.assign({
        branches: Object.values(branchesDB),
      });
      pubSub.broadcast("branches", { type: "updated" });
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

The delete case handles the delete event by removing the specified branch from the data store, updating the LiveView context with the updated list of branches, and broadcasting the changes to other components.

Now, add the delete button to the editButton component as follows:

function editButton(id: string) {
  return html`
    <button class="bg-blue-700 text-white text-sm py-2 px-4 rounded-md" phx-click="edit" phx-value-id="${id}">Edit</button>
    <button class="bg-red-700 text-white text-sm py-2 px-4 rounded-md" phx-click="delete" phx-value-id="${id}" phx-disable-with="Deleting...">Delete</button>
  `;
}
Enter fullscreen mode Exit fullscreen mode

Users should be able to delete a branch by clicking on the delete button: Delete Button To Delete A Deactivated Branch To test the final version of our demo application, run the following command:

npm run dev
Enter fullscreen mode Exit fullscreen mode

To test the real-time interactivity in our demo web application, open http://localhost:4001 with your browser and also do the same in incognito mode. Notice how the branches are created, updated, and deleted in real time.

You just built a real-time interactive web application with fewer lines of code compared to using any SPA or frontend framework. How easy was that?

Conclusion

In this tutorial, we took a look at some comparisons between the LiveView model and SPAs, demonstrated how to implement LiveViews in Node.js, and successfully built a full-stack bank management application with support for real-time interactivity. There are so many ways this can be improved, and I can’t wait to see what you build next with LiveViewJS.

You can find the complete source code on GitHub.


200s only✓ Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third-party services are successful, try LogRocket.

LogRocket Signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.

Top comments (0)