Megan Lee
Posted on April 17, 2024
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" });
This reads the context (current state) of the LiveView
:
socket.context.foo
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",
});
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);
});
Using a client hook:
this.handleEvent("my-event", (event) => {
console.log(event.foo);
});
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 })
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")
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>
This example would send the following event to the server:
{
type: "mark_complete",
id: "myId"
}
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:
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
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>
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;
};
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;
}
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 typeT
-
#changeset
: This property holds the factory function for creating changesets. It's used to create changesets for validating and modifying data - **
#pubSub
and#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;
}
}
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);
}
}
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];
}
}
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");
}
}
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;
}
}
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;
}
}
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;
}
}
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 });
}
}
}
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";
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),
});
Next, we'll infer the Branch
type from the BranchSchema
:
type Branch = z.infer<typeof BranchSchema>;
Then, add the following to create the branch LiveViewChangesetFactory
:
const branchCSF = newChangesetFactory<Branch>(BranchSchema);
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> = {};
Add the following to create a pub/sub for publishing changes:
const pubSub = new SingleProcessPubSub();
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 = "";
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) => { }
})
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,
});
}
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),
});
}
}
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;
}
},
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>
`;
},
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:
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;
}
}
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>
Then add the phx_submit: "save"
to the form_for
helper function as follows:
${form_for<Branch>("#", csrfToken, {
phx_submit: "save",
phx_change: "validate",
})}
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>
`;
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>
`;
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>
`;
}
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:
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;
}
}
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;
}
}
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;
}
}
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>
`;
}
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>
`;
}
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>
`;
}
Users can now toggle between disabled and activated for the status of each branch as well as update the branch details:
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;
}
}
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>
`;
}
Users should be able to delete a branch by clicking on the delete button: To test the final version of our demo application, run the following command:
npm run dev
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 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.
Posted on April 17, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.