Vanilla JavaScript Templating

February 7, 2023

Sometimes I just am looking to add a small amount of interactivity or state onto an HTML page, perhaps in a Wordpress plugin or a Github pages site. I don't want to have to set up a full-fledged JavaScript framework like Svelte or React just to get simple templating to work, because chances are that I'll spent more time with the setup than I would with the actual page itself.

Using ES6 template literals can be a good substitute to get much of the funtionality of a full view library while sticking with native JavaScript. Here's an example of a simple photo gallery:

<div id="photo-gallery"></div>

const target = document.getElementById('photo-gallery');
const photoUrls = [
  'https://picsum.photos/id/100/200',
  'https://picsum.photos/id/101/200',
  'https://picsum.photos/id/102/200',
];
let currentPhoto = 0;

const render = () => {
  target.innerHTML = `
    <img src="${photoUrls[currentPhoto]}" />
    <div>
      <button class="prev">Previous</button>
      <button class="next">Next</button>
    </div>
  `;

  target.querySelector('.next')
        .addEventListener('click', () => {
    currentPhoto++;
    if (currentPhoto >= photoUrls.length) {
      currentPhoto = 0;
    }
    render();
  });

  target.querySelector('.prev')
        .addEventListener('click', () => {
    currentPhoto--;
    if (currentPhoto < 0) {
      currentPhoto = photoUrls.length;
    }
    render();
  });
}

render();
    
    

We can also use template literals to do more advanced templating, with calls to map and conditionals. Here's an example of a simple to-do list:

<div id="todo-list"></div>
#todo-list { font-family: sans-serif; }
    .todo:hover { cursor: pointer;}
    .done { text-decoration: line-through; }

const target = document.getElementById("todo-list");
const todos = [
  { text: "Buy milk", done: false },
  { text: "Buy eggs", done: true },
  { text: "Buy bread", done: false }
];

const render = () => {
  target.innerHTML = `
    <h1>Todo List</h1>
    <p>Click on an item to toggle it</p>
    <ul>${
      todos.map(
        (todo) => `
        <li class="todo ${todo.done ? "done" : ""}">
          ${todo.text}
        </li>
      `).join("")}
    </ul>
    <input type="text" id="new-todo" />
    <button id="add-todo">Add Todo</button>
  `;

  target.querySelectorAll(".todo").forEach((todo, index) => {
    todo.addEventListener("click", () => {
      todos[index].done = !todos[index].done;
      render();
    });
  });

  target.querySelector("#add-todo").addEventListener("click", () => {
    const input = target.querySelector("#new-todo");
    todos.push({ text: input.value, done: false });
    input.value = "";
    render();
  });
};

render();


  

Note that there's a bug in the above code; if you enter something into the text input, and then click on a todo item, the text input will be cleared. This is because the render function is called after the todo item is clicked, which does a DOM render from scratch, clearing the input text. To resolve this, we will need to preserve the text input as it is being entered.

To illustrate this, we will use a more advanced example of an RSVP form.

<div id="rsvp-form"></div>
#rsvp-form { font-family: sans-serif; }


const target = document.getElementById("rsvp-form");
const state = {
  name: "",
  email: "",
  attending: false,
  hasRestriction: false,
  restriction: "",
  submitted: false,
};

const render = () => {
  if (state.submitted) {
    target.innerHTML = `
      <h1>Thank you!</h1>
      <p>Here is what you submitted:</p>
      <ul>
        <li>Name: ${state.name}</li>
        <li>Email: ${state.email}</li>
        <li>Attending: ${state.attending ? "Yes" : "No"}</li>
        <li>Dietary Restriction: ${state.restriction || "None"}</li>
      </ul>
    `;
    return;
  }

  target.innerHTML = `
    <h1>RSVP</h1>
    <p>Please fill out the form below</p>
    <form>
      <label for="name">Name</label>
      <input type="text" value="${state.name}" data-var="name" />
      <br />
      <label for="email">Email</label>
      <input type="text" value="${state.email}" data-var="email" />
      <br />
      <label for="attending">Attending</label>
      <input type="checkbox" ${state.attending ? "checked" : ""} data-var="attending" />
      <br />
      <label for="attending">Do you have a dietary restriction?</label>
      <input type="checkbox" ${state.hasRestriction ? "checked" : ""} data-var="hasRestriction" />
      <br />
      ${
        state.hasRestriction ? `
            <label for="dietary-restrictions">Please specify:</label>
            <input type="text" value="${state.restriction}" data-var="restriction" />
            <br />
          `
        : ''
      }
      <button type="submit" id="submit">Submit</button>
    </form>
  `;

  target.querySelectorAll("input[type=text]").forEach((elem) => {
    elem.addEventListener("input", (event) => {
      state[event.target.dataset.var] = event.target.value;
    });
  });

  target.querySelectorAll("input[type=checkbox]").forEach((elem) => {
    elem.addEventListener("change", (event) => {
      state[event.target.dataset.var] = event.target.checked;
      render();
    });
  });

  target.querySelector("form").addEventListener("submit", (event) => {
    event.preventDefault();
    state.submitted = true;
    render();
  });
}

render();


  

In the above example, checking the box for dietary restriction does not cause the input fields to clear out. This is because listeners have been attached to all input fields in the form to save their states.

These examples serve to illustrate the power of JavaScript template literals to render complex HTML, with support for conditionals, lists of items, and state preservation. Of course, above a certain level of complexity, it may be better to use a full-fledged frontend library such as React, Vue or Svelte. But, for simple projects where only a bit of state or interactivity is needed, consider if Vanilla JavaScript can do the job.