Handling attributes and event listeners: Let's code a virtual DOM! #4

Handling attributes and event listeners: Let's code a virtual DOM! #4

Adding attributes, event listeners and styles to DOM

This is the 4th part of the series where we build a virtual DOM from scratch.

If you haven't already, you can check out the previous parts here:

What will we create

So far, our virtual DOM library is capable of rendering the elements and managing the application state. Whenever the state changes, virtual DOM updates only the elements that need to be updated.

You can see the current state of the development here:

But right now, we are not able to pass any attributes to the DOM, nor we are able to set up event listeners.

Let's change that!

Passing attributes to DOM

We'll have to create two functions that will handle the DOM attributes: setAttributes and updateAttributes.

setAttributes will be called whenever we add a new element to DOM. updateAttributes will be called whenever we need to update the DOM node (we need to make sure the attributes are in sync between DOM and virtual DOM).

To accommodate these new functions let's create a new file in /src/attributes.ts.

Setting standard HTML attributes

setAttrubutes function will loop over the props passed down to the virtual DOM, and set them as an attribute on the element.

// /src/attributes.ts

export const setAttributes = (el: HTMLElement, attributes: Attributes) => {
  for (const [attribute, value] of Object.entries(attributes)) {
    el.setAttribute(attribute, String(value));
  }
};

We need to call this function every time, a new node is created. To do that let's modify our createElement function in /src/diff.ts file:

// /src/diff.ts

const createElement = (node: any) => {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }

  const el = document.createElement(node.type);
  setAttributes(el, node.props);
  node.children.map(createElement).forEach(el.appendChild.bind(el));

  return el;
};

Now we can create a simple Form and see what HTML has been created:

const app = document.querySelector<HTMLDivElement>('#app')!;

interface Model {
  email: string;
  password: string;
}

render<Model>(app, {
  model: { email: 'test@example.com', password: 'aVerySecretPassword' },
  view(state) {
    return h('div', {}, [
      h('form', { method: 'POST', action: '#' }, [
        h('input', { type: 'email', value: state.email }),
        h('input', { type: 'password', value: state.password }),
        h('button', { type: 'submit' }, ['Submit']),
      ]),
    ]);
  },
  update(state) {
    return state;
  },
});

The resulting HTML is:

Zrzut ekranu 2022-08-28 o 19.24.11.png

It seems that everything's working! Well... until we try to add a more complex attribute like style.

Setting style attribute

h('div', { style: { background: '#dddddd' } }, [])

Results in:

Zrzut ekranu 2022-08-28 o 19.31.16.png

To work around that, we need to handle styles differently.

export const setAttributes = (el: HTMLElement, attributes: Attributes) => {
  for (const [attribute, value] of Object.entries(attributes)) {
    if (attribute === 'style') {
      const styles = Object.entries(value);

      for (const [key, val] of styles) {
        el.style[key] = val;
      }
    } else {
      el.setAttribute(attribute, String(value));
    }
  }
};

After we add this modification, we can see that adding styles now works properly:

h(
  'div',
  {
    style: {
      background: '#dddddd',
      padding: '10px',
    },
  },
  [
    h(
      'form',
      {
        method: 'POST',
        action: '#',
        style: { display: 'flex', flexDirection: 'column' },
      },
      [
        h('input', { type: 'email', value: state.email }),
        h('input', { type: 'password', value: state.password }),
        h('button', { type: 'submit', style: { marginTop: '10px' } }, [
          'Submit',
        ]),
      ]
    ),
  ]
);

Is now rendered as:

Zrzut ekranu 2022-08-28 o 19.41.33.png

Setting event listeners

Before we move on to the update function, we need to do one more thing.

If we pass an event listener to the props object, it will not work:

h(
  'form',
  {
    style: { display: 'flex', flexDirection: 'column' },
    onSubmit: (e) => e.preventDefault(),
  },
  []
);

Translates to:

Zrzut ekranu 2022-08-28 o 19.49.17.png

The solution is to filter out the event handlers, and add them using addEventListener.

export const setAttributes = (el: HTMLElement, attributes: Attributes) => {
  for (const [attribute, value] of Object.entries(attributes)) {
    if (attribute === 'style') {
      const styles = Object.entries(value);

      for (const [key, val] of styles) {
        el.style[key] = val;
      }
    } else if (/^on/.test(attribute)) {
      el.addEventListener(
        attribute.slice(2).toLowerCase(),
        value as EventListener
      );
    } else {
      el.setAttribute(attribute, String(value));
    }
  }
};

Now, running this code will show an alert in the browser, every time the form is submitted:

render<Model>(app, {
  model: { email: 'test@example.com', password: 'aVerySecretPassword' },
  view(state) {
    return h(
      'div',
      {
        style: {
          background: '#dddddd',
          padding: '10px',
        },
      },
      [
        h(
          'form',
          {
            style: { display: 'flex', flexDirection: 'column' },
            onSubmit: (e) => {
              e.preventDefault();
              alert(1);
            },
          },
          [
            h('input', { type: 'email', value: state.email }),
            h('input', { type: 'password', value: state.password }),
            h('button', { type: 'submit', style: { marginTop: '10px' } }, [
              'Submit',
            ]),
          ]
        ),
      ]
    );
  },
  update(state) {
    return state;
  },
});

Updating the attributes

Let's build something useful. A to-do list maybe? That's the programmer's favourite project :)

Here is the code:

import { h, render } from './dom';

const app = document.querySelector<HTMLDivElement>('#app')!;

interface Model {
  text: string;
  todos: string[];
}

enum Actions {
  UPDATE_TEXT = 'UPDATE_TEXT',
  ADD_TODO = 'ADD_TODO',
}

render<Model>(app, {
  model: { text: '', todos: [] },
  view(state, dispatch) {
    return h(
      'main',
      {
        style: {
          display: 'flex',
          flexDirection: 'column',
        },
      },
      [
        h('h1', {}, ['To-do List']),
        h(
          'ul',
          {},
          state.todos.map((todo) => h('li', {}, [todo]))
        ),
        h('div', {}, [
          h('input', {
            value: state.text,
            onInput: (e: any) =>
              dispatch({ type: Actions.UPDATE_TEXT, payload: e.target.value }),
          }),
          h(
            'button',
            {
              style: { marginLeft: '5px' },
              onClick: () => dispatch({ type: Actions.ADD_TODO }),
            },
            ['Add To-do']
          ),
        ]),
      ]
    );
  },
  update(state, action) {
    switch (action.type) {
      case Actions.UPDATE_TEXT:
        return { ...state, text: action.payload };
      case Actions.ADD_TODO:
        if (!state.text) return state;

        return { ...state, text: '', todos: [...state.todos, state.text] };
      default:
        return state;
    }
  },
});

This code renders a simple UI:

Zrzut ekranu 2022-08-28 o 20.11.10.png

After we fill in the text field and press the 'Add to-do' button, we'll see that the to-do item is being added correctly. However, the input has not been cleared as we would expect.

Zrzut ekranu 2022-08-28 o 20.13.19.png

It's happening because we didn't update the attributes in the DOM after the state changed.

Let's fix that:

Updating changed attributes

We'll start by implementing the updateAttributes function:

export const updateAttributes = (
  el: HTMLElement,
  oldAttributes: Attributes,
  newAttributes: Attributes
) => {
  const allAttributes = Object.keys({ ...newAttributes, ...oldAttributes });

  for (const attribute of allAttributes) {
    const oldVal = oldAttributes[attribute];
    const newVal = newAttributes[attribute];

    if (!newVal) {
      removeAttribute(el, attribute, oldVal);
    } else if (!oldVal || newVal !== oldVal) {
      removeAttribute(el, attribute, oldVal);
      setAttribute(el, attribute, newVal);
    }
  }
};

With that code, we check what are the attributes from the old node, and what are the new ones. Then, if needed, we add a new attribute and/or remove the old one.

There are some more functions that we need to create. Namely: setAttribute (as opposed to setAttributes) and removeAttribute.

Here is the code:

export type AttributeVal = number | string | boolean | Function;

export interface Attributes {
  [x: string]: AttributeVal;
}

const removeAttribute = (
  el: HTMLElement,
  attribute: string,
  value: AttributeVal
) => {
  if (attribute === 'style') {
    const styles = Object.entries(value);

    for (const [key] of styles) {
      el.style.removeProperty(key);
    }
  } else if (attribute === 'value') {
    el.value = '';
  } else if (/^on/.test(attribute)) {
    el.removeEventListener(
      attribute.slice(2).toLowerCase(),
      value as EventListener
    );
  } else {
    el.removeAttribute(attribute);
  }
};

const setAttribute = (
  el: HTMLElement,
  attribute: string,
  value: AttributeVal
) => {
  if (attribute === 'style') {
    const styles = Object.entries(value);

    for (const [key, val] of styles) {
      el.style[key] = val;
    }
  } else if (attribute === 'value') {
    el.value = value;
  } else if (/^on/.test(attribute)) {
    el.addEventListener(
      attribute.slice(2).toLowerCase(),
      value as EventListener
    );
  } else {
    el.setAttribute(attribute, String(value));
  }
};

We have done several things here:

  • refactored the code to reuse the setAttrubute function from the setAttributes function
  • added a check if an attribute is a value attribute - it needs to be handled in a different manner

Now we can incorporate that with the rest of our code. Here is the complete /src/attributes.ts file:

export type AttributeVal = number | string | boolean | Function;

export interface Attributes {
  [x: string]: AttributeVal;
}

const removeAttribute = (
  el: HTMLElement,
  attribute: string,
  value: AttributeVal
) => {
  if (attribute === 'style') {
    const styles = Object.entries(value);

    for (const [key] of styles) {
      el.style.removeProperty(key);
    }
  } else if (attribute === 'value') {
    el.value = '';
  } else if (/^on/.test(attribute)) {
    el.removeEventListener(
      attribute.slice(2).toLowerCase(),
      value as EventListener
    );
  } else {
    el.removeAttribute(attribute);
  }
};

const setAttribute = (
  el: HTMLElement,
  attribute: string,
  value: AttributeVal
) => {
  if (attribute === 'style') {
    const styles = Object.entries(value);

    for (const [key, val] of styles) {
      el.style[key] = val;
    }
  } else if (attribute === 'value') {
    el.value = value;
  } else if (/^on/.test(attribute)) {
    el.addEventListener(
      attribute.slice(2).toLowerCase(),
      value as EventListener
    );
  } else {
    el.setAttribute(attribute, String(value));
  }
};

export const setAttributes = (el: HTMLElement, attributes: Attributes) => {
  for (const [attribute, value] of Object.entries(attributes)) {
    setAttribute(el, attribute, value);
  }
};

export const updateAttributes = (
  el: HTMLElement,
  oldAttributes: Attributes,
  newAttributes: Attributes
) => {
  const allAttributes = Object.keys({ ...newAttributes, ...oldAttributes });

  for (const attribute of allAttributes) {
    const oldVal = oldAttributes[attribute];
    const newVal = newAttributes[attribute];

    if (!newVal) {
      removeAttribute(el, attribute, oldVal);
    } else if (!oldVal || newVal !== oldVal) {
      removeAttribute(el, attribute, oldVal);
      setAttribute(el, attribute, newVal);
    }
  }
};

The last missing piece is to call the updateAttributes function:

In the /src/diff.ts file we need to modify diff function like this:

export const diff = (
  root: HTMLElement,
  oldNode: Element | null,
  newNode: Element,
  index: number = 0
) => {
  if (!oldNode) {
    root.appendChild(createElement(newNode));
  } else if (!newNode) {
    root.removeChild(root.childNodes[index]);
  } else if (hasChanged(oldNode, newNode)) {
    root.replaceChild(createElement(newNode), root.childNodes[index]);
  } else if (typeof newNode !== 'string' && typeof oldNode !== 'string') {
    updateAttributes(
      root.childNodes[index] as HTMLElement,
      oldNode.props,
      newNode.props
    );

    const oldLength = oldNode.children.length;
    const newLength = newNode.children.length;

    for (let i = 0; i < newLength || i < oldLength; i++) {
      diff(
        root.childNodes[index] as HTMLElement,
        oldNode.children[i],
        newNode.children[i],
        i
      );
    }
  }
};

At this point, our app is working perfectly!

Nagranie-z-ekranu-2022-08-28-o-2.gif

Conclusion

Adding attributes to DOM is not difficult - we were able to implement this in less than 80 lines of code.

Check out the code for this blog post:

Of course, there are a few more things that can be improved. I'll cover them in the next post from this series. If you don't want to miss it, make sure to subscribe to my newsletter!

Thanks for reading, and see you in the next post!

Did you find this article valuable?

Support Krzysztof Kałamarski by becoming a sponsor. Any amount is appreciated!