How to create a collaborative to-do list with JavaScript and Liveblocks

The goal of this tutorial is to show you how to build a collaborative to-do list app in 15 minutes. The to-dos will be persisted on Liveblocks backend and synced in real-time across clients. Users will also be able to see who’s currently using the app and when someone is typing.

We’re going to use vanilla JavaScript and esbuild for the bundling.

If you’re using a front-end framework such as React, we recommend reading one of our dedicated to-do list tutorials:

The source code for this guide is available on GitHub.

Install Liveblocks into your project

Install Liveblocks packages

Create an empty node project with npm init and run the following command to install Liveblocks package and esbuild:

$npm install @liveblocks/client esbuild

@liveblocks/client lets you interact with Liveblocks servers. esbuild lets your bundle your app with @liveblocks/client.

Then add a build script to your package.json that will be responsible to bundle our app.

"scripts": {  "build": "esbuild app.js --bundle --outfile=static/app.js"},

Connect to Liveblocks servers

You’ll need an API key in order to use Liveblocks. Create a Liveblocks account to get your API key. It should start with pk_.

To connect to Liveblocks servers, create a client with createClient and set your public API key like below.

app.js
import { createClient } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});

Connect to a Liveblocks room

A room is the virtual space where people collaborate. To create a collaborative experience, you’ll need to connect your users to a Liveblocks room following the instructions below.

You can easily connect to a room by using client.enter by passing the room id as a parameter. For this tutorial we’ll use javascript-todo-app.

app.js
import { createClient } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, });}
run();

We’ve also passed an initialPresence value here—we’ll be using this later.

Show who’s currently in the room

Now that Liveblocks is set up, we’re going to use room.subscribe("others") to show who’s currently inside the room.

Create a file static/index.html with the following content:

static/index.html
<!doctype html><html lang="en">  <head>    <meta charset="utf-8" />    <title>Liveblocks - Todo list</title>    <link rel="stylesheet" href="index.css" />  </head>  <body>    <div class="container">      <div id="who_is_here" class="who_is_here"></div>    </div>    <script src="app.js"></script>  </body></html>

And replace app.js content with the code below, build your app with npm run build and open static/index.html in multiple browser windows.

app.js
import { createClient } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, });
const whoIsHere = document.getElementById("who_is_here");
room.subscribe("others", (others) => { whoIsHere.innerHTML = `There are ${others.count} other users online`; });}
run();

If you want to make your app feel less "brutalist" while following along, create a file static/index.css with the following CSS.

Show if someone is typing

Any users in the room can be typing, so we need to have a state isTyping per connected user. This state is only temporary, it is not persisted after users leave the room. Liveblocks has a concept of "presence" to handle this kind of temporary states. For example, a user "presence" can be used to share the cursor position or the selected shape if your building a design tool.

We’re going to use room.updatePresence hook to set the presence of the current user.

First, add an input to static/index.html

static/index.html
<!doctype html><html lang="en">  <head>    <meta charset="utf-8" />    <title>Liveblocks - Todo list</title>    <link rel="stylesheet" href="index.css" />  </head>  <body>    <div class="container">      <div id="who_is_here" class="who_is_here"></div>      <input id="todo_input" type="text" placeholder="What needs to be done?" />    </div>    <script src="app.js"></script>  </body></html>

Then listen to keydown and blur to detect when the user is typing.

App.js
import { createClient } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, });
const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input");
room.subscribe("others", (others) => { whoIsHere.innerHTML = `There are ${others.count} other users online`; });
todoInput.addEventListener("keydown", (e) => { // Clear the input when the user presses "Enter". // We'll add todo later on if (e.key === "Enter") { room.updatePresence({ isTyping: false }); todoInput.value = ""; } else { room.updatePresence({ isTyping: true }); } });
todoInput.addEventListener("blur", () => { room.updatePresence({ isTyping: false }); });}
run();

Now that we set the isTyping state when necessary, add a new div to display a message when at least one other user has isTyping equals to true.

static/index.html
<!doctype html><html lang="en">  <head>    <meta charset="utf-8" />    <title>Liveblocks - Todo list</title>    <link rel="stylesheet" href="index.css" />  </head>  <body>    <div class="container">      <div id="who_is_here" class="who_is_here"></div>      <input id="todo_input" type="text" placeholder="What needs to be done?" />      <div id="someone_is_typing" class="someone_is_typing"></div>    </div>    <script src="app.js"></script>  </body></html>
app.js
import { createClient } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, });
const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing");
room.subscribe("others", (others) => { whoIsHere.innerHTML = `There are ${others.count} other users online`;
someoneIsTyping.innerHTML = others .toArray() .some((user) => user.presence?.isTyping) ? "Someone is typing..." : ""; });
todoInput.addEventListener("keydown", (e) => { // Clear the input when the user presses "Enter". // We'll add todo later on if (e.key === "Enter") { room.updatePresence({ isTyping: false }); todoInput.value = ""; } else { room.updatePresence({ isTyping: true }); } });
todoInput.addEventListener("blur", () => { room.updatePresence({ isTyping: false }); });}
run();

Sync and persist to-dos

As opposed to the presence, some collaborative features require that every user interacts with the same piece of state. For example, in Google Doc, it is the paragraphs, headings, images in the document. In Figma, it’s all the shapes that make your design. That’s what we call the room’s storage.

The room’s storage is a conflicts-free state that multiple users can edit at the same time. It is persisted to our backend even after everyone leaves the room. Liveblocks provides custom data structures inspired by CRDTs that can be nested to create the state that you want.

  • LiveObject - Similar to JavaScript object. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner.
  • LiveList - An ordered collection of items synchronized across clients. Even if multiple users add/remove/move elements simultaneously, LiveList will solve the conflicts to ensure everyone sees the same collection of items.
  • LiveMap - Similar to a JavaScript Map. If multiple users update the same property simultaneously, the last modification received by the Liveblocks servers is the winner.

We’re going to store the list of todos in a LiveList. Initialize the storage with the initialStorage option when entering the room. Then we use LiveList.push when the user press "Enter".

src/App.js
import { createClient, LiveList } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
async function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, initialStorage: { todos: new LiveList() }, });
const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing");
const { root } = await room.getStorage();
let todos = root.get("todos");
room.subscribe("others", (others) => { /* ... */ });
todoInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { room.updatePresence({ isTyping: false }); todos.push({ text: todoInput.value }); todoInput.value = ""; } else { room.updatePresence({ isTyping: true }); } });
todoInput.addEventListener("blur", () => { room.updatePresence({ isTyping: false }); });}
run();

At this point, the todos are added to the storage but they are not rendered! Add a container for our todos and use room.subscribe(todos) to get rerender the app whenever the todos are updated.

static/index.html
<!doctype html><html lang="en">  <head>    <meta charset="utf-8" />    <title>Liveblocks - Todo list</title>    <link rel="stylesheet" href="index.css" />  </head>  <body>    <div class="container">      <div id="who_is_here" class="who_is_here"></div>      <input id="todo_input" type="text" placeholder="What needs to be done?" />      <div id="someone_is_typing" class="someone_is_typing"></div>      <div id="todos_container"></div>    </div>    <script src="app.js"></script>  </body></html>
src/App.js
import { createClient, LiveList } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
async function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, initialStorage: { todos: new LiveList() }, });
const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing"); const todosContainer = document.getElementById("todos_container");
const { root } = await room.getStorage();
let todos = root.get("todos");
room.subscribe("others", (others) => { /* ... */ });
todoInput.addEventListener("keydown", (e) => { /* ... */ });
todoInput.addEventListener("blur", () => { /* ... */ });
function renderTodos() { todosContainer.innerHTML = "";
for (let i = 0; i < todos.length; i++) { const todo = todos.get(i);
const todoContainer = document.createElement("div"); todoContainer.classList.add("todo_container");
const todoText = document.createElement("div"); todoText.classList.add("todo"); todoText.innerHTML = todo.text; todoContainer.appendChild(todoText);
todosContainer.appendChild(todoContainer); } }
room.subscribe(todos, () => { renderTodos(); });
renderTodos();}
run();

Finally, add a delete button for each todo and call LiveList.delete to remove a todo from the list by index.

src/App.js
import { createClient, LiveList } from "@liveblocks/client";
const client = createClient({ publicApiKey: "",});
async function run() { const { room, leave } = client.enterRoom("javascript-todo-app", { initialPresence: { isTyping: false }, initialStorage: { todos: new LiveList() }, });
const whoIsHere = document.getElementById("who_is_here"); const todoInput = document.getElementById("todo_input"); const someoneIsTyping = document.getElementById("someone_is_typing"); const todosContainer = document.getElementById("todos_container");
const { root } = await room.getStorage();
let todos = root.get("todos");
room.subscribe("others", (others) => { /* ... */ });
todoInput.addEventListener("keydown", (e) => { /* ... */ });
todoInput.addEventListener("blur", () => { /* ... */ });
function renderTodos() { todosContainer.innerHTML = "";
for (let i = 0; i < todos.length; i++) { const todo = todos.get(i);
const todoContainer = document.createElement("div"); todoContainer.classList.add("todo_container");
const todoText = document.createElement("div"); todoText.classList.add("todo"); todoText.innerHTML = todo.text; todoContainer.appendChild(todoText);
const deleteButton = document.createElement("button"); deleteButton.classList.add("delete_button"); deleteButton.innerHTML = "✕"; deleteButton.addEventListener("click", () => { todos.delete(i); }); todoContainer.appendChild(deleteButton);
todosContainer.appendChild(todoContainer); } }
room.subscribe(todos, () => { renderTodos(); });
renderTodos();}
run();

In this tutorial, we discovered what’s a room, how to connect and enter a room. And how to use the room’s api to interact with its presence and storage.

You can see some stats about the room you created in your dashboard.

Liveblocks dashboard

Next steps