Search code examples
javascriptvue.jsvuejs2vuexstate-management

For sibling communication between many identical components, how should I store the data in the lowest-common ancestor?


Background:

  • I'm a Python/Vue developer; I've been using Vue since 2016.
  • I have a client who runs a weight loss / meal planning business: clients pay her to prepare weekly single-page PDF menus that tell them (the clients) exactly what to eat for breakfast, lunch, and dinner of every day of the week. (image of an example menu)
    • Each meal is shown as a list of ingredients.
  • Right now she's preparing these menus in Excel, and she hired me to reproduce and extend the functionality of what she has in Excel, but in a Python/Vue app.
  • The app I'm building for her has many "pages" ("top-level" components) to allow her to add/modify/delete objects like clients, ingredients, and recipes (image), but the most complicated part of the UI is the component in which she can define the meals for every meal of every day of the week (image). That component is named WeeklyMenu.vue.
  • WeeklyMenu.vue itself contains seven DailyMenu.vue children, one for each day of the week (Monday, Tuesday, etc.). (image)
  • Each DailyMenu.vue component itself contains four Meal.vue components, one for each of four meal types: Breakfast, Lunch, Dinner, and Snacks. (image)
  • Important: At the moment, the DailyMenu.vue and Meal.vue components themselves contain their data rather than accessing it from the Vuex store.
    • For example, the list of ingredients for each meal is contained within the Meal.vue component as a mealIngredients variable within the component's data attribute. (image)
    • Side-note: This means that there are lots of HTTP requests being sent to the back-end when the page loads as all of the meals are requesting their own data, rather than a single request being sent via a Vuex action (for example). This seems like it can't be best practice.

The problem:

  • The problem is that she is now asking me to add features in which a change to the data in one subcomponent should update the data in a different subcomponent.
    • For example, she wants the app to work so that when she has the same recipe in several different Meals of the week, then a change to an ingredient in one of the meals will propagate to the other meals that have the same recipe. (image explanation)

My question:

What is the best practice for handling a situation like this? Should I move the ingredient data into the Vuex store or (in the same vein) the lowest-common-ancestor WeeklyMenu.vue component? If so, how exactly should it work? Should there be a separate variable for each meal? Or should I have an object that contains data for all of the different meals? If I use a single object, do I need to worry that a watcher on that object in the Meal.vue component would be triggering even when a change was made to a different meal's data?

If I store all the meal ingredients in separate variables, I would need to pass all of those to every meal (so every meal would need to receive every other meal's ingredients as separate props). So that doesn't seem like the right way to go.

If a user is making a particular change to a particular meal, how would I only have the other meals with the same name react?

Related links:

Simplified example of the situation I'm trying to handle:


Solution

  • I ended up getting it working in a simple example in CodePen, which I'm going to use as a guide when trying to get it working on the actual site.

    The summary of my findings with this solution is, "Vue will actually update when the nested entries of a Vuex state object are updated; you don't need to worry about it not detecting those changes. So it's OK to just keep all the data in a single big Vuex store object when you have many duplicate sibling components that need to react to each other."

    Here's the CodePen: https://codepen.io/NathanWailes/pen/NWRNgNz

    Screenshot

    enter image description here

    Summary of what the CodePen example does

    • The data used to populate the menu all lives in the Vuex store in a single weeklyMenu object, which has child objects to break up the data into the different days / meals.
    • The individual meals have computed properties with get and set functions so that it can both get changes from the store and also update the store.
    • The DailyMenu and WeeklyMenu components get their aggregate data by simply having computed properties that iterate over the Vuex weeklyMenu object, and it "just works".
    • I have same-named meals update to match each other by iterating over the meals in the Vuex mutation and looking for meals with the same "Ingredient Name".

    The code

    HTML

    <html>
      <body>
        <div id='weekly-menu'></div>
        <h3>Requirements:</h3>
        <ul>
          <li>Each row should have all the numbers in it summed and displayed ('total daily calories').</li>
          <li>The week as a whole should have all the numbers summed and displayed ('total weekly calories').</li>
          <li>If two or more input boxes have the same text, a change in one numerical input should propagate to the other same-named numerical inputs.</li>
          <li>Ideally the data (ingredient names and calories) should be stored in one place (the top-level component or a Vuex store) to make it more straightforward to populate it from the database with a single HTTP call (which is not simulated in this example).</li>
        </ul>
      </body>
    </html>
    

    JavaScript

    const store = new Vuex.Store(
      {
        state: {
          weeklyMenu: {
            Sunday: {
              Breakfast: {
                name: 'aaa',
                calories: 1
              },
              Lunch: {
                name: 'bbb',
                calories: 2
              },
            },
            Monday: {
              Breakfast: {
                name: 'ccc',
                calories: 3
              },
              Lunch: {
                name: 'ddd',
                calories: 4
              },
            }
          }
        },
        mutations: {
          updateIngredientCalories (state, {dayOfTheWeekName, mealName, newCalorieValue}) {
            state.weeklyMenu[dayOfTheWeekName][mealName]['calories'] = newCalorieValue
            
            const ingredientNameBeingUpdated = state.weeklyMenu[dayOfTheWeekName][mealName]['name']
            for (const dayOfTheWeekName of Object.keys(state.weeklyMenu)) {
              for (const mealName of Object.keys(state.weeklyMenu[dayOfTheWeekName])) {
                const mealToCheck = state.weeklyMenu[dayOfTheWeekName][mealName]
                const ingredientNameToCheck = mealToCheck['name']
                if (ingredientNameToCheck === ingredientNameBeingUpdated) {
                  mealToCheck['calories'] = newCalorieValue
                }
              }
            }
          },
          updateIngredientName (state, {dayOfTheWeekName, mealName, newValue}) {
            state.weeklyMenu[dayOfTheWeekName][mealName]['name'] = newValue
          }
        }
      }
    )
    
    var Meal = {
      template: `
        <td>
          <h4>{{ mealName }}</h4>
          Ingredient Name: <input v-model="ingredientName" /><br/>
          Calories: <input v-model.number="ingredientCalories" />
        </td>
      `,
      props:    [
        'dayOfTheWeekName',
        'mealName'
      ],
      computed: {
        ingredientCalories: {
          get () {
            return this.$store.state.weeklyMenu[this.dayOfTheWeekName][this.mealName]['calories']
          },
          set (value) {
            if (value === '' || value === undefined || value === null) {
              value = 0
            }
            this.$store.commit('updateIngredientCalories', {
              dayOfTheWeekName: this.dayOfTheWeekName,
              mealName: this.mealName,
              newCalorieValue: value
            })
          }
        },
        ingredientName: {
          get () {
            return this.$store.state.weeklyMenu[this.dayOfTheWeekName][this.mealName]['name']
          },
          set (value) {
            this.$store.commit('updateIngredientName', {
              dayOfTheWeekName: this.dayOfTheWeekName,
              mealName: this.mealName,
              newValue: value
            })
          }
        }
      }
    };
    
    var DailyMenu = {
      template: `
        <tr>
          <td>
            <h4>{{ dayOfTheWeekName }}</h4>
            Total Daily Calories: {{ totalDailyCalories }}
          </td>
          <meal :day-of-the-week-name="dayOfTheWeekName" meal-name="Breakfast" />
          <meal :day-of-the-week-name="dayOfTheWeekName" meal-name="Lunch" />
        </tr>
      `,
      props:    [
        'dayOfTheWeekName'
      ],
      data: function () {
        return {
        }
      },
      components: {
               meal: Meal
      },
      computed: {
        totalDailyCalories () {
          let totalDailyCalories = 0
          for (const mealName of Object.keys(this.$store.state.weeklyMenu[this.dayOfTheWeekName])) {
            totalDailyCalories += this.$store.state.weeklyMenu[this.dayOfTheWeekName][mealName]['calories']
          }
          return totalDailyCalories
        }
      }
    };
    
    var app = new Vue({ 
        el: '#weekly-menu',
      template: `<div id="weekly-menu" class="container">
        <div class="jumbotron">
        <h2>Weekly Menu</h2>
        Total Weekly Calories: {{ totalWeeklyCalories }}
        <table class="table">
            <tbody>
              <daily_menu day-of-the-week-name="Sunday" />
              <daily_menu day-of-the-week-name="Monday" />
            </tbody>
        </table>
        </div>
    </div>
    `,
      data: function () {
        return {
        }
      },
      computed: {
        totalWeeklyCalories () {
          let totalWeeklyCalories = 0
          for (const dayOfTheWeekName of Object.keys(this.$store.state.weeklyMenu)) {
            let totalDailyCalories = 0
            for (const mealName of Object.keys(this.$store.state.weeklyMenu[dayOfTheWeekName])) {
              totalDailyCalories += this.$store.state.weeklyMenu[dayOfTheWeekName][mealName]['calories']
            }
            totalWeeklyCalories += totalDailyCalories
          }
          return totalWeeklyCalories
        }
      },
      components: {
               daily_menu: DailyMenu
      },
      store: store
    });