This website uses Cookies and LocalStorage

Making a TODO app in Isotope

Post cover

So, I've just unveiled my new open-source UI library called Isotope. It's fast, lightweight, modular and overall - I think it's pretty good.

Anyway, if you're interested in trying something new and fresh, maybe consider giving Isotope a try? You can go straight up to the docs or bear with me, as we're going to make a simple TODO app, allowing us to learn the basics of Isotope.

Setup

Isotope is written in TypeScript that's transpiled down to pure JS, which requires no additional tooling to get you up & running.

To set up our project, we'll use npm (but yarn is also an option). We'll start by running run npm init to create our base package.json file. Then, install the Isotope and Bulma - a CSS-only library that will make our app look slightly prettier!

npm install @isotope/core bulma

Now, you can use Isotope with any bundler you want (or go buildless), but here, we'll use the Parcel - a zero-config bundler that doesn't require any setup whatsoever, and thus it's great for any kind of playground-like scenario!

npm install --dev parcel-bundler

With the bundler installed, we can start writing some code, or more specifically, the HTML!

<!DOCTYPE html>
<html>
  <head>
    <title>Isotope Playground</title>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <script src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
  </head>

  <body>
    <script src="src/index.js"></script>
  </body>
</html>

Aside from the basic boilerplate, we also load the Font Awesome icon library through its CDN and include our main JS file, which is where the whole magic will happen. And that's it for HTML! Parcel will take care of the rest. Just make sure you've got all the files in correct places and run npx parcel index.html to start the dev server.

Container

So, now that we're all set up, let's get right into making our app. First, we have to create the a container that will house all our TODOs, as well as a form to add them.

import { createDOMView } from "@isotope/core";
import "bulma/css/bulma.min.css";

const view = createDOMView(document.body);
const container = view
  .main({
    classes: ["container", "fluid"],
  })
  .div({
    classes: ["columns", "is-centered", "is-vcentered", "is-mobile"],
  })
  .div({
    classes: ["column", "is-narrow"],
    styles: {
      width: "70%",
    },
  });

In the snippet above we create our main container. We start by importing the createDOMView() function from the Isotope library, which is responsible for creating a view - a top-level node, that attaches to the specified DOM element to render its content.

Here, we attach our view to the <body> element, making Isotope effectively take control of the entire website. It's a nice solution for our current situation, but keep in mind that Isotope's progressive nature, allows it to attach to any element to control even the smallest pieces of your UI.

So, we've got our top-level node attached to the <body> element. This is a great start for our application. In Isotope a node is the most important entity and having access to even a single one, grants you the power to create more.

That's essentially what we do in the next line.

// ...
const container = view.main({
  classes: ["container", "fluid"],
});
// ...

We use the view reference we've got to create a new node - a child node that will append a new element to the DOM. For that, we use the main() method - a method from the Isotope's HTML node pack.
Isotope's node packs are essentially bundles of shortcut methods that get applied directly to the node's prototype. main() is one of such methods. It simplifies the creation of the <main> element, which would otherwise require a bit longer syntax (child("main")).

To configure our new node, we have to use a configuration object. Here, we make use of the classes config property, to add some CSS classes to the element.

So, to summarize, we create a new node which represents a <main> element - child to <body> - that has "container" and "fluid" CSS classes applied to it. On a side note - all of the used class names come from Bulma, which we import at the top of our JS file thanks to Parcel CSS imports support.

The main() like all other methods from the HTML node pack, returns the newly-created node. In this way we get the ability to add new child nodes to this node, effectively building our UI.

const container = view
  .main({
    classes: ["container", "fluid"],
  })
  .div({
    classes: ["columns", "is-centered", "is-vcentered", "is-mobile"],
  })
  .div({
    classes: ["column", "is-narrow"],
    styles: {
      width: "70%",
    },
  });

As you can see, when setting up our container, we put this chainability of Isotope to a good use. In the end, it's the last node in the chain that gets assigned to the container variable. Also, notice how we use another configuration property - styles - to set CSS styles of the underlying element.

At the moment our HTML structure should look somewhat like this:

<body>
  <main>
    <div>
      <div></div>
    </div>
  </main>
</body>

Basic elements

Now that we've got the container, it's time to add some real elements to our app!

// ...
container
  .h1({
    classes: ["has-text-centered", "title"],
  })
  .text("Isotope TODO");
container.form();
container.ul();

Here we're adding 3 new child nodes to the container: header, form, and list. Apart from the usual stuff, notice how we use a special text() method to set the text of the created <h1> element.

Now, after the header, we create two more elements - <form> and <ul>. These 2 elements are where the rest of our app will be placed. With this in mind, it's easy to see how our code can become bloated over time pretty easily. To prevent that, we'll move both of these elements into separate components, which themselves will be placed within separate modules.

Creating components

In Isotope things are meant to be simple - and so are the components, which themselves are nothing more than simple functions. Take a look:

// src/form.js
const Form = (container) => {
  const form = container.form();

  return form;
};

export { Form };

Here, in a new file (src/form.js), we create a new Isotope component - Form. As you can see, it's a function that accepts a parent node, and optionally returns a new node.

Such a component can then be used through the $() method:

// src/index.js
// ...
import { Form } from "./form";
// ...
container.$(Form);

If the component function returns a node, then the same node is returned from the $() method. Otherwise, the $() method returns the node it was called upon (in our case it would be the container) for easier chaining.

As you can see, Isotope components are really easy to use. Let's now set up our List component as well.

// src/list.js
const List = (container) => {
  const list = container.ul();

  return list;
};

export { List };
// src/index.js
// ...
import { Form } from "./form";
import { List } from "./list";
// ...
container.$(Form);
container.$(List);

Building form

With our components set up, it's time to build our form for accepting new TODOs!

// src/index.js
const Form = (container) => {
  const form = container.form({
    classes: ["field", "has-addons"],
    styles: { justifyContent: "center" },
  });
  const input = form.div({ classes: ["control"] }).input({
    attribs: { type: "text", placeholder: "TODO" },
    classes: ["input"],
  });

  form
    .div({ classes: ["control"] })
    .button({ classes: ["button", "is-primary"] })
    .span({ classes: ["icon", "is-small"] })
    .i({ classes: ["fas", "fa-plus"] });

  return form;
};

export { Form };

So, above we create our form layout. As you can see, there's not much new when compared to what we already know. There's only the attribs configuration property that's used to set attributes of the node's DOM element.

Apart from that, you can also notice how helpful Isotope's method chaining capabilities can be when creating the submit button.

Reactivity

With our form ready, we now need to make it reactive. Isotope is a statically-dynamic UI library, which (apart from sounding cool) means that it has a bit different approach to reactivity. Instead of making the entire UI reactive out-of-the-box, Isotope requires you to specifically mark certain nodes as dynamic by either creating their own state or by linking them to other dynamic nodes. For the purpose of our TODO app, we'll explore both of these ways.

First, we have to identify what kind of data should be made reactive. In our case - it's the list of TODOs that we'll operate on, and the current user input for creating new TODOs.

So, we've got 2 properties to create in our state - input and todos. The state should be accessible by both the Form (to write to input), as well as List (to display TODOs) component. Thus, I think it'll be best to initialize our state on the container node.

// src/index.js
// ...
const container = view
  .main({
    classes: ["container", "fluid"],
  })
  .div({
    classes: ["columns", "is-centered", "is-vcentered", "is-mobile"],
  })
  .div({
    classes: ["column", "is-narrow"],
    state: {
      input: "",
      todos: [],
    },
    styles: {
      width: "70%",
    },
  });
// ...

So, we go back to our index.js file and set up our state on the last node (the one that's assigned to the container variable. To do this, we make use of the state property, supplying it with our state object, containing initial values. And that's it! - Now our container is reactive!

Event handling

Let's get back to the src/form.js file and put this reactivity to good use. First, we'll handle the <form> element itself.

// src/form.js
const Form = (container) => {
  // ...
  form.on("submit", (event) => {
    const input = container.getState("input");
    const todos = container.getState("todos");

    if (input) {
      container.setState({
        input: "",
        todos: [
          ...todos,
          {
            text: input,
            id: Math.random().toString(36).substr(2, 9),
          },
        ],
      });
    }

    event.preventDefault();
  });
  // ...
};
// ...

On the form node, we use the on() method to listen to the submit event of the <form> element. Isotope provides a set of event-related methods (on(), off() and emit()), which are universal and can be used to handle all kinds of events - DOM, custom, and Isotope-related ones.

In our handling function, we first access the input and todos properties from the container's state. Remember that Isotope doesn't handle data passing on its own - you need to do that by having a reference to a stateful node, through custom events or in any other way you find suitable. In our case, because the container that holds the data is also the direct parent of our component, we can use that reference to access its state.

Isotope provides 2 methods to work with the state - getState() and setState(). To access one of state properties, you have to pass its key to the getState() method. That's what we do to access the input and todos properties.

After that, we check whether the user has entered anything in the form (i.e. if the input isn't empty) and if so, we transform it into a new TODO. In our case, a TODO is an object with text and id property, where text contains TODO's actual content, and id is a random string, to help us identify a given TODO later on.

We use the setState() method to update the container's state. The method accepts an object that should be applied on top of the previous state. It doesn't have to include all the properties the original state object had, but we assign both anyway. input gets assigned an empty string to clean the value of <input> element, while todos is assigned a new array. Know that because arrays are passed by reference in JavaScript, you can as well use the push() method on the todos variable that we've got from the getState() call. It's just a matter of personal preference as to which way you prefer. Just know that you'll eventually have to call the setState() method (even with an empty object), to let Isotope know that it should update the node.

Lifecycle events

Now we'll move to our input node to get it set up as well.

// src/form.js
const Form = (container) => {
  // ...
  const input = form
    .div({ classes: ["control"] })
    .input({
      attribs: { type: "text", placeholder: "TODO" },
      classes: ["input"],
    })
    .on("input", ({ target }) => {
      container.setState({ input: target.value });
    })
    .on("node-updated", ({ node }) => {
      node.element.value = container.getState("input");
    });
  // ...
};
// ...

Here, we once again use Isotope's chainability (on() method returns the node it was called upon) to listen to 2 events one after another. First, we handle the input event, which is native to HTML <input> element. Inside the listener, we use the setState() method, to set the value of input property to the current input.

Next up, we listen to one of Isotope's node lifecycle events - node-updated. This event is emitted every time a node updates - either via a change in state or in the result of a link. The listener is passed an object with node property, giving it access to the node the listener is connected to. We use that reference to access the node's underlying HTML element through the element property and set it's value to the value of input property from the container's state.

Through the code above, we've gained complete control over the <input> element. It's value is completely reliant on the value of the container's state.

Linking

With the event listeners in place, our form is almost done. The last issue we have to solve is related to the node-updated event our input node is listening to. The problem is that it'll never be triggered as the node neither has its own state, nor it's linked to any other nodes.

To fix that issue, we have to write one magic line:

// src/form.js
// ...
container.link(input);
// ...

With the use of the link() method, we link the input node to the container. Linking in Isotope allows us to let one node know that it should update when the other one does so. What we do with the line above is letting input know that it should update (thus triggering the node-updated event) every time the container's state is changed.

It's important to remember that linking can happen between any 2 nodes - no matter where they are in the hierarchy. A single node can have multiple nodes linked to itself, but it can be linked only to a single node.

Displaying TODOs

Now that our form is ready and can accept new TODOs, we have to take care of displaying them.

Let's get back to our List component and start our work:

// src/list.js
const List = (container) => {
  const list = container.ul({
    classes: () => ({
      list: container.getState("todos").length > 0,
    }),
  });
  container.link(list);

  return list;
};

export { List };

First, we make a few changes to our base list node. We use the classes configuration property, but in a bit different way than usual. Instead of passing an array of CSS class names, we pass a function, which returns an object. In this way, we let Isotope know that it should rerun the function and update CSS classes every time the node updates. The value that the function returns gets later applied like usual.

An object that the function returns is an alternative way of  applying CSS class names. The object's keys represent certain CSS class names and their values - booleans that indicate whether the given CSS class should be applied or removed. As a side note, other configuration properties (attribs and styles) also accept a similar function configuration.

So, we apply the "list" CSS class name only when our TODOs list contains at least one TODO. But, in order for our dynamic classes to work, we also have to link the list node to the container, which we do in the next line.

List rendering

Now that we've got our <ul> element set up, we only need to display our TODOs. In Isotope, this can be done with a special map() method.

// src/list.js
// ...
list.map(
  () => container.getState("todos"),
  ({ id, text }, node) => {
    const item = node.li({ classes: ["list-item"] });
    const itemContainer = item.div({
      classes: ["is-flex"],
      styles: { alignItems: "center" },
    });

    itemContainer.span({ classes: ["is-pulled-left"] }).text(text);
    itemContainer.div({ styles: { flex: "1" } });
    itemContainer
      .button({
        classes: ["button", "is-text", "is-pulled-right", "is-small"],
      })
      .on("click", () => {
        const todos = container.getState("todos");
        const index = todos.findIndex((todo) => todo.id === id);

        container.setState("todos", todos.splice(index, 1));
      })
      .span({ classes: ["icon"] })
      .i({ classes: ["fas", "fa-check"] });

    return item;
  }
);
// ...

map() takes 2 arguments - the list of items to map and a function used to map them. The items list can have multiple forms. For static lists it can be an array of unique strings, numbers or objects with an id key. For dynamic lists, where items get modified on the way, you can pass parent's state property key, or a function that determines the items, as we do above. Because todos is a property of container's state - not the list's, a function is the only solution we have.

Inside the mapping function, we get access to the current item (in our case items are objects with text and id properties), the parent node (list) and the index of the current item. We only use 2 of those values.

Overall, the rest of the code is nothing new - we create nodes, set their CSS classes, styles, attributes and text, and listen to the click event on the button, to remove a certain TODO when needed.

What do you think?

So, with that, our TODO app is ready. You can check out the finished results through the CodeSandbox playground, right here:

To summarize, through making this very simple app, we've learned pretty much most of the Isotope API. That's right - it's that simple. Remember that although the API and the library itself is small and simple, it can still be used to create really incredible and very performant apps and websites!

If you like what you see, definitely check out Isotope's documentation, and drop a star on its GitHub repo!

For more content about Isotope and web development as a whole, follow me on Twitter, Facebook or through my newsletter.

Author

Read more

Post cover

Modern Web Extension Development with TypeScript

Diving deep into Web Extension development with the latest tooling…

Read More
Post cover

Simplistic static websites with Docking.js

Introductory tutorial to making your own static website with Docking.js and the Isotope ecosystem…

Read More
Post cover

Making a TODO App in Svelte

A guide to making your first steps into the Svelte world with a simple TODO app…

Read More

Comments