DEV Community

Cover image for The Power of Memento Design Pattern in JavaScript
jsmanifest
jsmanifest

Posted on

The Power of Memento Design Pattern in JavaScript

Image description

The Memento Pattern in programming is useful in situations where we need a way to restore an object's state.

As a JavaScript developer we work with this concept in many situations especially now in modern web applications.

If you've been developing in the web for some time then you might have heard of the term hydration.

If you don't know what hydration is, it's a technique in web development where the client side takes static content which was stored in any programming language such as JSON, JavaScript, HTML, etc. and converts it into code where browsers are able to run during runtime. At that stage JavaScript is run and is able to do things like attach event listeners when the DOM begins running on the page.

The memento pattern is similar. In this post we are going to implement the Memento pattern for the runtime and will not be storing anything statically.

If you worked with JSON.parse and JSON.stringify chances are you might have accidentally implemented a memento before.

Usually there are three objects that implement the full flow of the Memento pattern:

  1. Originator
  2. Memento
  3. Caretaker

The Originator defines the interface that triggers the creation and storing of itself as the memento.

The Memento is the internal state representation of the Originator that is passed and retrieved from the Caretaker.

The Caretaker has one job: to store or save the memento to be used later. It can retrieve the memento it stored but it does not mutate anything.

Implementing the Memento Design Pattern

Now that we described the pattern we are going to implement it to master this practice in code.

We will be creating an interactive email input field as a DOM element. We are going to add one smart behavior to our input field so that our user will immediately become aware that they need to add the @ symbol before submitting.

They will know this when their input field is in an error state which will look like this:

memento-input-error1-overview1.png

This is the html markup we are going to work right on top of:

<!DOCTYPE html>
<html>
  <head>
    <title>Memento</title>
    <meta charset="UTF-8" />
  </head>
  <body style="margin:50px;text-align:center;background:linear-gradient(
    76.3deg,
    rgba(44, 62, 78, 1) 12.6%,
    rgba(69, 103, 131, 1) 82.8%
  );height:250px;overflow:hidden;">
    <input type="email" id="emailInput" style="padding:12px;border-radius:4px;font-size:16px;" placeholder="Enter your email"></input>
    <script src="src/index.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This will start us off with this interface:

memento-state-pattern-overview2.png

Now the first thing we are going to do is define a couple of constant variables for the error state that we will use throughout our code to assign as values to the error styles. This is to ensure that we don't make any typos when writing our code since we will be reusing them multiple times:

const ERROR_COLOR = 'tomato'
const ERROR_BORDER_COLOR = 'red'
const ERROR_SHADOW = `0px 0px 25px rgba(230, 0, 0, 0.35)`
const CIRCLE_BORDER = '50%'
const ROUNDED_BORDER = '4px'
Enter fullscreen mode Exit fullscreen mode

This has nothing to do with the pattern but I think it's a good habit for me to randomly slip in some best practices just so you can get extra tips from this post, why not right? ;)

Now we are going to create a helper function that toggles between the error state and the normal state since we are going to be using this multiple times as well:

const toggleElementStatus = (el, status) => {
  if (status === 'error') {
    return Object.assign(el.style, {
      borderColor: ERROR_BORDER_COLOR,
      color: ERROR_COLOR,
      boxShadow: ERROR_SHADOW,
      outline: 'red',
    })
  }
  return Object.assign(el.style, {
    borderColor: 'black',
    color: 'black',
    boxShadow: '',
    outline: '',
  })
}
Enter fullscreen mode Exit fullscreen mode

I might as well just slip in a helper to toggle the border radius while we toggle between the two style presets. This is to make our code feel more "natural" as if it was a real app so we don't just focus directly on the relationship between the colors and the memento in this post. Sometimes I think we learn better when we also see the perspective of random code vs the actual code that we are going over with:

const toggleBorderRadius = (el, preset) => {
  el.style.borderRadius =
    preset === 'rounded'
      ? ROUNDED_BORDER
      : preset === 'circle'
      ? CIRCLE_BORDER
      : '0px'
}
Enter fullscreen mode Exit fullscreen mode

The next thing we are going to do is write the Originator.

Remember, the originator defines the interface that triggers the creation and storing of itself as the memento.

function createOriginator({ serialize, deserialize }) {
  return {
    serialize,
    deserialize,
  }
}
Enter fullscreen mode Exit fullscreen mode

Actually, we just created a simply factory that produces the originator for us.

Here is the real originator:

const originator = createOriginator({
  serialize(...nodes) {
    const state = []

    nodes.forEach(
      /**
       * @param { HTMLInputElement } node
       */
      (node) => {
        const item = {
          id: node.id || '',
        }

        item.tagName = node.tagName.toLowerCase()

        if (item.tagName === 'input') {
          item.isError =
            node.style.borderColor === ERROR_BORDER_COLOR &&
            node.style.color === ERROR_COLOR
          item.value = node.value
        }

        item.isRounded = node.style.borderRadius === ROUNDED_BORDER
        item.isCircle = node.style.borderRadius === CIRCLE_BORDER

        state.push(item)
      },
    )

    return state
  },
  deserialize(...state) {
    const providedNode = state[state.length - 1]

    if (providedNode) state.pop()

    const nodes = []

    state.forEach((item) => {
      const node = providedNode || document.createElement(item.tagName)

      if (item.tagName === 'input') {
        if (item.isError) {
          toggleElementStatus(node, 'error')
        }
        if (item.isRounded) {
          toggleBorderRadius(node, 'rounded')
        } else if (item.isCircle) {
          toggleBorderRadius(node, 'circle')
        }
        node.value = item.value || ''
        if (item.placeholder) node.placeholder = item.placeholder
        if (item.id) node.id = item.id
      }

      nodes.push(node)
    })

    return nodes
  },
})
Enter fullscreen mode Exit fullscreen mode

In the originator, the serialize method takes in a DOM node and returns us a state representation of the DOM node so that we can store it inside the local storage as a string. This is required because the local storage only accepts strings.

Right now we are the peak of this pattern in JavaScript. The serialization is the only reason why this pattern is important to us otherwise we'd be able to directly store DOM nodes to the local storage and call it a day.

Inside our serialize method we implicitly defined a couple of rules that help us determine the representation.

Here are the lines I'm referring to:

if (item.tagName === 'input') {
  item.isError =
    node.style.borderColor === ERROR_BORDER_COLOR &&
    node.style.color === ERROR_COLOR
  item.value = node.value
}

item.isRounded = node.style.borderRadius === ROUNDED_BORDER
item.isCircle = node.style.borderRadius === CIRCLE_BORDER
Enter fullscreen mode Exit fullscreen mode

When storing mementos of input elements we have a choice whether to implement it that way or this way:

if (item.tagName === 'input') {
  item.style.borderColor = node.style.borderColor
  item.style.color = node.style.color
  item.value = node.value
}

item.style.borderRadius = node.style.borderRadius
Enter fullscreen mode Exit fullscreen mode

Take my advice on this: A good practice is to create useful meaning out of your code especially in your design pattern implementations. When you inaugurate meaning in your code it it helps you to think of higher level abstractions that might be useful in other areas of your code.

Using item.isError to represent a preset of error styles opens up wider opportunities to make interesting reusable mementos that we can reuse as our project grows more complex over time as opposed to assigning arbitrary styles directly.

For example, it's common for forms to not submit when a crucial field is left unblank. The form must transition to some kind of state where it needs to stop itself from submitting.

If we were to save a memento of a form we need to ensure that when we restore this state the user is restored to the "disabled" state:

const originator = createOriginator({
  serialize(...nodes) {
    const state = []

    nodes.forEach(
      /**
       * @param { HTMLInputElement } node
       */
      (node) => {
        const item = {
          id: node.id || '',
        }

        item.tagName = node.tagName.toLowerCase()

        if (item.tagName === 'input') {
          item.isError =
            node.style.borderColor === ERROR_BORDER_COLOR &&
            node.style.color === ERROR_COLOR
          item.value = node.value
        }

        item.isRounded = node.style.borderRadius === ROUNDED_BORDER
        item.isCircle = node.style.borderRadius === CIRCLE_BORDER

        if (node.textContent) item.textContent = node.textContent

        state.push(item)
      },
    )

    return state
  },
  deserialize(state) {
    const nodes = []

    if (!Array.isArray(state)) state = [state]

    state.forEach((item) => {
      const node = document.createElement(item.tagName)

      if (item.style) {
        Object.entries(item.style).forEach(([key, value]) => {
          node.style[key] = value
        })
      }

      if (item.isRounded) {
        toggleBorderRadius(node, 'rounded')
      } else if (item.isCircle) {
        toggleBorderRadius(node, 'circle')
      }

      if (item.spacing) {
        node.style.padding = item.spacing
      }

      if (item.id) node.id = item.id

      if (item.tagName === 'input') {
        if (item.isError) {
          toggleElementStatus(node, 'error')
        }
        node.value = item.value || ''
        if (item.placeholder) node.placeholder = item.placeholder
      } else if (item.tagName === 'label') {
        if (item.isError) {
          node.style.color = ERROR_COLOR
        }
      } else if (item.tagName === 'select') {
        if (item.options) {
          item.options.forEach((obj) => {
            node.appendChild(...originator.deserialize(obj, node))
          })
        }
      }

      if (item.textContent) node.textContent = item.textContent

      nodes.push(node)
    })

    return nodes
  },
})

const caretaker = createCaretaker()

function restore(state, container, { onRendered } = {}) {
  let statusSubscribers = []
  let status = ''

  const setStatus = (value, options) => {
    status = value
    statusSubscribers.forEach((fn) => fn(status, options))
  }

  const renderMemento = (memento, container) => {
    return originator.deserialize(memento).map((el) => {
      container.appendChild(el)

      if (memento.isError && status !== 'error') {
        setStatus('error')
      }

      if (memento.children) {
        memento.children.forEach((mem) => {
          renderMemento(mem, el).forEach((childEl) => el.appendChild(childEl))
        })
      }

      return el
    })
  }

  const render = (props, container) => {
    const withStatusObserver = (fn) => {
      statusSubscribers.push((updatedStatus) => {
        if (updatedStatus === 'error') {
          // Do something
        }
      })

      return (...args) => {
        const elements = fn(...args)
        return elements
      }
    }

    const renderWithObserver = withStatusObserver(renderMemento)

    const elements = renderWithObserver(props, container)
    statusSubscribers.length = 0
    return elements
  }

  const elements = render(state, container)

  if (onRendered) onRendered(status, elements)

  return {
    status,
    elements,
  }
}

const container = document.getElementById('root')

const { status, elements: renderedElements } = restore(mementoJson, container, {
  onRendered: (status, elements) => {
    if (status === 'error') {
      const submitBtn = container.querySelector('#submit-btn')
      submitBtn.disabled = true
      submitBtn.textContent = 'You have errors'
      toggleElementStatus(submitBtn, 'error')
    }
  },
})
Enter fullscreen mode Exit fullscreen mode

Instead of returning the elements directly we make sure that what's also returned is the current state of rendering the memento.

Looking at this in a higher level perspective we take advantage of the fact that isError can represent and overview of something like a form. A form should not be submitted if either one little required field is missing or a value was not entered correctly.

In that case we make sure that the form should not be interactive by disabling the submit button right before displaying to the user:

memento-pattern-with-error-state-disabled- button.png

If you haven't noticed, our restore wraps our original deserialize method from our Originator.

What we have now is a higher level abstracted memento that supports deep children and the rendering state (isError) of our entire memento.

Conclusion

And that concludes the end of this post! I hope you found this to be valuable and look out for more in the future!

Find me on medium

Top comments (0)