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

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:

%[https://kkalamarski.me/series/virtual-dom]

# 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: 

%[https://stackblitz.com/edit/virtual-dom-diff?file=src/main.ts]

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.

```typescript
// /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:

```typescript
// /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:

```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1661707456593/E79Tg9hS1.png align="center")

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

## Setting style attribute

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

Results in:

![Zrzut ekranu 2022-08-28 o 19.31.16.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1661707881346/O1K1FQ09O.png align="center")

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

```typescript
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:

```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1661708498880/0E983hbq-.png align="center")

## 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:

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

Translates to:

![Zrzut ekranu 2022-08-28 o 19.49.17.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1661708963164/sRkRhhJun.png align="center")

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


```typescript
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:

```typescript
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:

```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1661710275063/_j-hNnFca.png align="center")

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](https://cdn.hashnode.com/res/hashnode/image/upload/v1661710403347/unOC4QQ4d.png align="center")

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:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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](https://cdn.hashnode.com/res/hashnode/image/upload/v1661719040934/ktvQUmMAG.gif align="center")

# 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:

%[https://stackblitz.com/edit/virtual-dom-diff-uon8fm?file=src/main.ts]

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!
