Published on

Reactive JQuery using Vuex design pattern

Authors
  • avatar
    Name
    ZoruChan
    Twitter

Reactive JQuery using Vuex design pattern

I recently came across a project where I had to work with limited / legacy tools and frameworks. In particular, I had to build a dynamic web app frontend using only JQuery and Bootstrap.

Modern frameworks like React or Vue take care of state management (Model layer of UI), as well as passing data to our components and handling UI events while applying business logic — the Presenter layer. Non of these are included in JQuery, so it’s relatively easy to end up with a huge mess. JQuery simply manipulates the DOM, no more no less. I ended up solving this problem by applying a design pattern similar to Vuex.

Here is what this demo looks like:

https://www.youtube.com/watch?v=Sq__htDugYU

Here is the code for those who want to jump right in:

<div class="container">
  <div class="mx-auto mt-5">
    <div class="row">
      <div class="col">
        <h3>Products</h3>
      </div>
    </div>

    <div id="controls" class="d-flex my-5">
      <button id="btn-fetch-more" class="btn btn-primary d-inline">Fetch more</button>
      <select id="select-category" class="form-select d-inline mx-3" aria-label="Category">
        <option selected value="">Filter by Category</option>
        <option value="skirt">Skirt</option>
        <option value="trousers">Trousers</option>
      </select>
    </div>

    <div id="stats-wrapper">
      <div id="stats-container" class="my-3" />
    </div>

    <div id="products-wrapper">
      <div id="products-container" class="my-3" />
    </div>
  </div>
</div>

<script>
  ;(function () {
    const $statsContainer = $('#stats-container')
    const $productsContainer = $('#products-container')
    const $btnFetchMore = $('#btn-fetch-more')
    const $selectCategory = $('#select-category')

    // state - State management
    const state = {
      products: [],
      isLoading: false,
      category: null,
    }

    // actions - Mutate state
    const actions = {
      init: async () => {
        updateState('isLoading', true)
        const { data } = await network.fetchProducts()
        updateState('products', data)
        updateState('isLoading', false)
      },

      onBtnFetchMoreClick: async () => {
        updateState('isLoading', true)
        const { data } = await network.fetchMoreProducts()
        updateState('products', data)
        updateState('isLoading', false)
      },

      onSelectCategoryChange: (e) => {
        updateState('category', e.target.value)
      },
    }

    // getters - Format, compute data using state for passing on to view layer.
    const getters = {
      filterProducts: () => {
        if (!state.category) return state.products
        return state.products.filter((o) => o.category === state.category)
      },

      computeAverageProductPrice: () => {
        const currentProducts = getters.filterProducts()
        if (!currentProducts.length) return 0

        const averagePrice =
          currentProducts.map((o) => o.priceTaxIn).reduce((prev, curr) => prev + curr) /
          currentProducts.length

        return averagePrice
      },
    }

    // network - Network layer communicates with API. Do not modify state!
    const network = {
      fetchProducts: async () =>
        new Promise((resolve) => {
          const mockResponse = {
            status: 'ok',
            data: [
              {
                id: 1,
                name: 'Cute skirt',
                category: 'skirt',
                priceTaxIn: 50.0,
                isAvailable: true,
                inventory: 14,
                rating: 3.1,
                reviewCount: 132,
              },
              {
                id: 2,
                name: 'Fancy skirt',
                category: 'skirt',
                priceTaxIn: 98.0,
                isAvailable: true,
                inventory: 14,
                rating: 4.8,
                reviewCount: 52,
              },
            ],
          }

          resolve(mockResponse)
        }),

      fetchMoreProducts: async () =>
        new Promise((resolve) => {
          const mockResponse = {
            status: 'ok',
            data: [
              {
                id: 1,
                name: 'Cute skirt',
                category: 'skirt',
                priceTaxIn: 50.0,
                isAvailable: true,
                inventory: 14,
                rating: 3.1,
                reviewCount: 132,
              },
              {
                id: 2,
                name: 'Fancy skirt',
                category: 'skirt',
                priceTaxIn: 98.0,
                isAvailable: true,
                inventory: 14,
                rating: 4.8,
                reviewCount: 52,
              },
              {
                id: 3,
                name: 'Cool pants',
                category: 'trousers',
                priceTaxIn: 28.9,
                isAvailable: true,
                inventory: 23,
                rating: 4.6,
                reviewCount: 874,
              },
            ],
          }

          resolve(mockResponse)
        }),
    }

    const updateState = (key, value) => {
      state[key] = value
      render()
      // $(state).trigger("state.change", [key, value])
    }

    const render = () => {
      // render all components with current state
      Object.values(components).map((f) => {
        if (typeof f === 'function') {
          f.call()
        }
      })
    }

    const components = {
      StatsComponent: () => {
        const data = getters.computeAverageProductPrice()

        // reset
        $statsContainer.html('')

        // populate with data
        $statsContainer.append(`
          <div class="row">
            <div class="col">
              Average Product Price: <span class="text-danger">
                ${data.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
              </span>
            </div>
          </div>
        `)
      },

      ProductTableComponent: () => {
        const data = getters.filterProducts()

        // reset
        $productsContainer.html('')

        // init
        $productsContainer.append(`
          <div class="row border-bottom py-3">
              <div class="col-1 font-weight-bold">
                ID
              </div>
              <div class="col font-weight-bold">
                Name
              </div>
              <div class="col font-weight-bold">
                Category
              </div>
              <div class="col font-weight-bold">
                Price
              </div>
            </div>
        `)

        // populate with data
        $.each(data, (idx, o) => {
          $productsContainer.append(`
            <div class="row border-bottom py-3">
              <div class="col-1">
                ${o.id}
              </div>
              <div class="col">
                ${o.name}
              </div>
              <div class="col">
                ${o.category}
              </div>
              <div class="col">
                ${o.priceTaxIn.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}
              </div>
            </div>
          `)
        })
      },
    }

    const bindEvents = () => {
      $(state).on('state.change', render)
      $btnFetchMore.on('click', actions.onBtnFetchMoreClick)
      $selectCategory.on('change', actions.onSelectCategoryChange)
    }

    $(document).ready(async () => {
      bindEvents()
      await actions.init()
    })
  })()
</script>

State

This is where we save the state of our page. It’s worth noting that all data which can be computed from the state is excluded.

Actions

Actions can mutate the state. This is a bit different from Vuex, for simplicity I decided to skip mutations and focus on actions when mutating state. Actions can be triggered by UI events as well, such as button clicks.

Getters

Getter functions are used for accessing State. Components use getter functions to get formatted / filtered etc. data to display.

Network

Implements the network layer for communicating with some API. The network layer does not mutate the state. Actions call the network layer and commit the mutations themselves.

Render

Renders all the reactive parts on the whole page using the current state.

Components

Our “functional components” live here. They utilize getters to satisfy their data needs, then reset and render themselves.

Conclusion

Never want to work with JQuery anymore, however this approach made my life a LOT easier and helped me understand the design decisions behind Vuex. Hope it might help someone facing the same problem.