Skip to main content

Building a real-time Todo app with Drayman, MongoDB and Tailwind CSS

· 9 min read

In this guide we will build a real-time Todo app with Drayman, MongoDB and Tailwind CSS.

If you are using regular tools, you would need to create a server with endpoints, a client app using some modern framework or just vanilla JavaScript. With Drayman, however, it will be just a single script with 100 lines of code.

If you are new to Drayman, you can go to our official docs to check out how it works or to read other guides.

Prerequisites#

This guide assumes that you:

When Drayman project is ready and you have MongoDB up and running, we can start developing our Todo app by adding Tailwind CSS.

Setting up Tailwind CSS#

First, you'll need to install autoprefixer and tailwindcss:

npm install -D tailwindcss@latest autoprefixer@latest

Next, generate tailwind.config.js and postcss.config.js files with this command:

npx tailwindcss init -p

Now modify tailwind.config.js file to exclude non-used CSS classes on compile:

tailwind.config.js
module.exports = {  purge: [    "./src/**/*.tsx",  ],  darkMode: false,  theme: {    extend: {},  },  variants: {    extend: {},  },  plugins: [],};

Create your main CSS file at src/styles.css and append this code:

src/styles.css
@tailwind base;@tailwind components;@tailwind utilities;

The final step is to modify public/index.html file to include the generated CSS file. Drayman will generate it to the public/styles.css file:

public/index.html
<!DOCTYPE html><html>  <head>    <meta charset="utf-8" />    <meta http-equiv="X-UA-Compatible" content="IE=edge" />    <title>Drayman Framework</title>    <meta name="viewport" content="width=device-width, initial-scale=1" />    <script src="/drayman-framework-client.js"></script>    <link rel="stylesheet" href="styles.css" />  </head>
  <body>    <drayman-element component="home"></drayman-element>
    <script>      initializeDraymanFramework();    </script>  </body></html>

Creating a Todo app#

We will start by adding basic UI using Tailwind CSS classes and then improve our solution to include real-time functionality and MongoDB support.

Adding basic UI#

Paste this code inside src/components.home.tsx:

src/components/home.tsx
export const component: DraymanComponent = async () => {  return async () => {    return (      <div class="flex justify-center items-center">        <div class="flex flex-col pt-8 lg:w-2/5 sm:w-3/5 w-11/12 gap-4">          <div class="flex gap-2">            <input              placeholder="New Todo"              class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"            />            <button              class={`bg-blue-500 text-white font-bold py-2 px-4 rounded`}            >              Save            </button>          </div>          <div class="flex items-center gap-2">            <div              class={`flex flex-grow items-center gap-2 bg-gray-200 p-2 rounded cursor-pointer`}            >              <input class="flex-none" type="checkbox" />              <div class="flex-grow">Grab a coffee</div>            </div>            <button class="flex-none bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">              Delete            </button>          </div>        </div>      </div>    );  };};

If Tailwind CSS was successfully initialized, you should see this result in your browser:

final-result

Adding MongoDB support#

For this guide we will use Change Stream functionality of MongoDB to make our Todo list work in real-time. This way if something gets changed in the database, our application will reflect these changes. Let's modify our src/components/home.tsx component and see in detail what is happening inside our script:

src/components/home.tsx
import { MongoClient, ObjectId } from "mongodb";
interface Todo {  _id: ObjectId;  text: string;  done: boolean;}
export const component: DraymanComponent = async ({  forceUpdate,  ComponentInstance,}) => {  const uri = "YOUR_MONGODB_CONNECTION_STRING";  const client = new MongoClient(uri);  await client.connect();  const db = client.db("todo");  const todoListCollection = db.collection<Todo>("list");
  let todos = await todoListCollection.find().toArray();
  todoListCollection.watch().on("change", async (x) => {    if (x.operationType === "insert") {      todos.push(x.fullDocument as Todo);    } else if (x.operationType === "update") {      todos = todos.map((todo) => {        if (todo._id.equals(x.documentKey._id)) {          return { ...todo, ...x.updateDescription.updatedFields };        }        return todo;      });    } else if (x.operationType === "delete") {      todos = todos.filter((todo) => !todo._id.equals(x.documentKey._id));    }    await forceUpdate();  });
  ComponentInstance.onDestroy = async () => {    await client.close();  };
  return async () => {    return (      <div class="flex justify-center items-center">        <div class="flex flex-col pt-8 lg:w-2/5 sm:w-3/5 w-11/12 gap-4">          <div class="flex gap-2">            <input              placeholder="New Todo"              class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"            />            <button class="bg-blue-500 text-white font-bold py-2 px-4 rounded">              Save            </button>          </div>          {todos.map((x) => (            <div class="flex items-center gap-2">              <div                class={`flex flex-grow items-center gap-2 ${                  x.done ? `bg-green-200 line-through` : `bg-gray-200`                } p-2 rounded cursor-pointer`}              >                <input class="flex-none" checked={x.done} type="checkbox" />                <div class="flex-grow">{x.text}</div>              </div>              <button class="flex-none bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded">                Delete              </button>            </div>          ))}        </div>      </div>    );  };};

First, we have added a connection to our database (don't forget to change YOUR_MONGODB_CONNECTION_STRING to your actual connection string). Then we made the initial fetch of our Todo list by converting it to an array - let todos = await todoListCollection.find().toArray();.

Then we added real-time functionality by watching for changes to database todoListCollection.watch():

  • on insert a Todo will be pushed to todos array;
  • on update a specific Todo from todos array will be updated;
  • on delete a specific Todo from todos array will be deleted.

The component will reflect all these changes because we also call the forceUpdate function - if any change gets caught, the component will re-render itself.

Finally, we are using the onDestroy lifecycle method to close the connection to the database when a component instance gets destroyed.

We also have made changes to our UI by mapping through todos array and rendering each Todo and changing CSS classes dynamically when a Todo is done.

As a result, any change made inside the database (I am using TablePlus for this), gets reflected inside our component:

mongodb-realtime

Our final step will be to make the input field and buttons work as expected.

Adding functionality to UI elements#

We will start by modifying the <input> element:

src/components/home.tsx
// ...let newTodo: string;// ...
return async () => {  return (    // ...    <input      placeholder="New Todo"      class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"      value={newTodo}      oninput={async ({ value }) => {        newTodo = value;        await forceUpdate();      }}    />    // ...  );};

The variable newTodo was introduced. When the user types in something, the oninput event gets triggered that saves the input value to the newTodo variable thus providing a single source of truth - the input's value will always be inside newTodo.

Let's now modify the <button>Save</button> element:

src/components/home.tsx
<button  class={`bg-blue-500 text-white font-bold py-2 px-4 rounded ${    newTodo ? `hover:bg-blue-700` : `opacity-50 cursor-not-allowed`  }`}  disabled={!newTodo}  onclick={async () => {    await todoListCollection.insertOne({ text: newTodo, done: false });    newTodo = null;  }}>  Save</button>

We have modified class and disabled attributes to appear disabled when there is no input by the user (newTodo is empty). When the user clicks a button, onclick gets triggered and a new Todo gets inserted into the database. We also don't need to call forceUpdate here because it already was managed before when watching for database changes. Now we can input something, click the button and the result will appear in the browser:

input-save

Our final step will be to modify todos list mapping:

src/components/home.tsx
todos.map((x) => (  <div class="flex items-center gap-2">    <div      onclick={async () => {        await todoListCollection.updateOne(          { _id: x._id },          { $set: { done: !x.done } }        );      }}      class={`flex flex-grow items-center gap-2 ${        x.done ? `bg-green-200 line-through` : `bg-gray-200`      } p-2 rounded cursor-pointer`}    >      <input class="flex-none" checked={x.done} type="checkbox" />      <div class="flex-grow">{x.text}</div>    </div>    <button      class="flex-none bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"      onclick={async () => {        await todoListCollection.deleteOne({ _id: x._id });      }}    >      Delete    </button>  </div>));

Here we have added the onclick event handler for <div> to mark a Todo done or undone and the onclick event handler for <button>Delete</button> to delete a Todo from the database when it is clicked.

Our component is now complete and you can open a page with this component in multiple tabs to check real-time functionality:

todo-list-functionality

Final component script#

src/components/home.tsx
import { MongoClient, ObjectId } from "mongodb";
interface Todo {  _id: ObjectId;  text: string;  done: boolean;}
export const component: DraymanComponent = async ({  forceUpdate,  ComponentInstance,}) => {  const uri = "YOUR_MONGODB_CONNECTION_STRING";  const client = new MongoClient(uri);  await client.connect();  const db = client.db("todo");  const todoListCollection = db.collection<Todo>("list");
  let todos = await todoListCollection.find().toArray();  let newTodo: string;
  todoListCollection.watch().on("change", async (x) => {    if (x.operationType === "insert") {      todos.push(x.fullDocument as Todo);    } else if (x.operationType === "update") {      todos = todos.map((todo) => {        if (todo._id.equals(x.documentKey._id)) {          return { ...todo, ...x.updateDescription.updatedFields };        }        return todo;      });    } else if (x.operationType === "delete") {      todos = todos.filter((todo) => !todo._id.equals(x.documentKey._id));    }    await forceUpdate();  });
  ComponentInstance.onDestroy = async () => {    await client.close();  };
  return async () => {    return (      <div class="flex justify-center items-center">        <div class="flex flex-col pt-8 lg:w-2/5 sm:w-3/5 w-11/12 gap-4">          <div class="flex gap-2">            <input              placeholder="New Todo"              class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"              value={newTodo}              oninput={async ({ value }) => {                newTodo = value;                await forceUpdate();              }}            />            <button              class={`bg-blue-500 text-white font-bold py-2 px-4 rounded ${                newTodo ? `hover:bg-blue-700` : `opacity-50 cursor-not-allowed`              }`}              disabled={!newTodo}              onclick={async () => {                await todoListCollection.insertOne({                  text: newTodo,                  done: false,                });                newTodo = null;              }}            >              Save            </button>          </div>          {todos.map((x) => (            <div class="flex items-center gap-2">              <div                onclick={async () => {                  await todoListCollection.updateOne(                    { _id: x._id },                    { $set: { done: !x.done } }                  );                }}                class={`flex flex-grow items-center gap-2 ${                  x.done ? `bg-green-200 line-through` : `bg-gray-200`                } p-2 rounded cursor-pointer`}              >                <input class="flex-none" checked={x.done} type="checkbox" />                <div class="flex-grow">{x.text}</div>              </div>              <button                class="flex-none bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"                onclick={async () => {                  await todoListCollection.deleteOne({ _id: x._id });                }}              >                Delete              </button>            </div>          ))}        </div>      </div>    );  };};

Conclusion#

We have created a real-time Todo app with Drayman, MongoDB and Tailwind CSS in just 100 lines of code, inside a single script.

If this felt interesting to you, visit the official docs to deep-dive into Drayman framework!

Getting started with Drayman

· 4 min read

Drayman is a server-side component framework that allows you to use any available HTML element, web component or custom Drayman third-party component together with server-side code in a single script.

With Drayman, the browser only renders what the user should see - all logic and calculations happen server-side and UI is written using JSX syntax.

The best way to show Drayman capabilities is to create something with it. So let's get started.

File viewer component#

Let's build a component that allows the user to select a file from the file system and view it's contents.

First, you need to install Drayman. It can be done by running these commands:

npx @drayman/framework-init@latest my-appcd my-appnpm start

The website will be available at http://localhost:3033.

Initial component template#

Go to src/components/home.tsx and replace its contents with this code:

src/components/home.tsx
export const component: DraymanComponent = async () => {  return async () => {    return (      <>        <p>Select a file to view it directly from file system</p>        <select></select>        <br />        <pre></pre>      </>    );  };};

You will see an initial skeleton of our component. Further <select> will be used to show available files and <pre> will show contents of the selected file.

Filling select with options#

Because Drayman runs a component server-side, we can use any Node.js library. In our case we will use the fs module.

Let's read file names from the project root directory and fill <select> options with them:

src/components/home.tsx
import { promises as fs } from "fs";
export const component: DraymanComponent = async () => {  const files = (await fs.readdir("./")).filter((x) => x.includes("."));
  return async () => {    return (      <>        <p>Select a file to view it directly from file system</p>        <select>          {files.map((fileName) => (            <option>{fileName}</option>          ))}        </select>        <br />        <pre></pre>      </>    );  };};

Right now our component is only showing some elements without any interactivity. Our next step will be to add it.

Reacting to user actions#

We need to remember which file the user has selected to show its contents. It can be done by using the onchange event attribute and attaching a function that will be executed server-side. We also need to add the value attribute to each option so that select would know which option was selected.

Let's also add the fs.readFile function inside the <pre> tag so that Drayman would read file contents during re-render. We won't show the pre until a file is actually selected:

src/components/home.tsx
import { promises as fs } from "fs";
export const component: DraymanComponent = async () => {  const files = (await fs.readdir("./")).filter((x) => x.includes("."));  let selectedFile;
  return async () => {    return (      <>        <p>Select a file to view it directly from file system</p>        <select          onchange={async ({ value }) => {            selectedFile = value;          }}        >          {files.map((fileName) => (            <option value={fileName}>{fileName}</option>          ))}        </select>        <br />        {selectedFile && <pre>{await fs.readFile(selectedFile, "utf-8")}</pre>}      </>    );  };};

If you make a selection from the dropdown, you will see that nothing happens on the page - file contents don't appear. That is because with Drayman you must strictly tell when a component needs to be re-rendered. It can be done by using a special helper function forceUpdate.

Import it and add to the onchange event after the selected file was saved:

src/components/home.tsx
import { promises as fs } from "fs";
export const component: DraymanComponent = async ({  forceUpdate,}) => {  const files = (await fs.readdir("./")).filter((x) => x.includes("."));  let selectedFile;
  return async () => {    return (      <>        <p>Select a file to view it directly from file system</p>        <select          onchange={async ({ value }) => {            selectedFile = value;            await forceUpdate();          }}        >          {files.map((fileName) => (            <option value={fileName}>{fileName}</option>          ))}        </select>        <br />        {selectedFile && <pre>{await fs.readFile(selectedFile, "utf-8")}</pre>}      </>    );  };};

Now our component is complete and file contents are shown on select: final-result

Conclusion#

We have built a component that combines server-side logic and client-side view in a single script.

If this felt interesting to you, visit the official docs to deep-dive into Drayman framework!