loading...

Let’s build a recipe app using Vue 3 + Vite

n0n3br profile image Rogério Luiz Aques de Amorim ・12 min read

Vue 3 is just around the corner, and I’ve been building some apps from app-ideas github repostitory to practice. If you’re not aware of it, this repository is a collection of ideas to build an app and practice your skills. Each app comes complete with a description, a list of user stories and bonus objectives and all the resources you’ll need to achieve your objective. It even got an example app, so if you get stuck in some point you can check out how it’s done. In this article we’ll start to build the recipe app.

Until late april the best way to try out one of the hottest new features, the composition api, was to use it in a Vue 2 project, by executing the following vue-cli command on an already created project. You can find many articles on the Internet on how to do it, like this one:

What I Have Learned So Far about 'Vue-Composition-API'

If you don’t what the composition API is, maybe you should read the Vue team documentation about it before we start. As always, the documentation is very clear and concise:

API Reference | Vue Composition API

In April 20th Evan You introduced Vite, a tool to generate a Vue 3 app template, serve it for dev with no bundling and bundle it for production using rollup. I started using on the first day, and have to say I’m really impressed on what they’ve achieved yet. The server starts immediately, since it doesn’t have the need to bundle the application (the components are compiled on the fly and server to the browser as native es modules ) and it even got Hot Module Replacement, so whenever you change your code they’re instantly reflected on the browser. You can check their repository bellow to read the documentation and start coding right now:

vuejs/vite - An opinionated web dev build tool

Enough talking, it’s time to get our hands dirty and write the code.

Getting started

To start our Vite project, all we need is to run the following command:

// you can use npm/npx
npx create-vite-app vite-recipe-book
cd vite-recipe-book
npm install
npm run dev
// or yarn
yarn create vite-app vite-recipe-book
cd vite-recipe-book
yarn
yarn dev

Open your browser, point it to http://localhost:3000 address and we’re ready to go.

The routing

Our app will consist of a simple recipe book. We have two parts, the ingredients and the recipes. As you may know, a recipe is composed of many ingredients.

Since we got two separate parts, the best way to change between them is to use vue-router, the official vue routing solution.

For Vue 3 we can use Vue-router 4 version. It’s in alpha still, but since we’re not building a production app, it’s all fine. The repository of this upcoming version is listed bellow:

vuejs/vue-router-next

Let’s install the latest version as the time of writing this article, v4.0.0-alpha.11, by using the commands bellow:

npm i --save vue-router@v4.0.0-alpha.11
# or
yarn add vue-router@v4.0.0-alpha.11

The we have to create our router.js file. It’s a little bit different from the previous version. We create the history object, the routes array and use them to create our router.

import { createWebHistory, createRouter } from "vue-router";
import Home from "./components/Home.vue";
import Ingredients from "./components/Ingredients.vue";
import Recipes from "./components/Recipes.vue";
const history = createWebHistory();
const routes = [
  { path: "/", component: Home },
  { path: "/ingredients", component: Ingredients },
  { path: "/recipes", component: Recipes },
];
const router = createRouter({ history, routes });
export default router;

We haven’t created the components we are importing, we’ll get there soom.

To make use of our new created router, we have to make some changes to the main.js file, by importing our routing and telling the app to use it:

import { createApp } from "vue";
import App from "./App.vue";
import "./index.css";
import router from "./router";
createApp(App).use(router).mount("#app");

The other file we’ll have to change is App.vue to include the router-view component, so that the current router gets rendered:

<template>
  <router-view />
</template>
<script>
export default {
  name: 'App',
}
</script>

And that’s it. Now let’s build our components.

Since we have routes, the first thing well create is …

The Nav Component

Our simple nav component will be a list of the 3 routes we created earlier. To make this, we’ll use the composition api and the useRouter hook provided by vue-router. Although we don’t need the composition api for simple components like this, we’ll use it everywhere to practice. So just create a Nav.vue file in your components folder and write the code:

<template>
  <nav>
    <router-link to="/">Vite Recipe Book</router-link>
    <ul>
      <li v-for="route in routes" :key="route.path">
        <router-link :to="route.to" :class="{ active: isActive(route.to) }">{{route.text}}</router-link>
      </li>
    </ul>
  </nav>
</template>

<script>
import { computed } from "vue";
import { useRouter } from "vue-router";
export default {
  setup() {
    const routes = [
      { to: "/ingredients", text: "Ingredients" },
      { to: "/recipes", text: "Recipes" }
    ];
    const router = useRouter();
    const activeRoute = computed(() => router.currentRoute.value.path);
    const isActive = path => path === activeRoute.value
    return { isActive, routes };
  }
};
</script>

As you saw, we only return from the setup method the parts that will be used outside.The router object and the activeRoute computed value are only used inside the setup method, so we don’t need to return them. The activeRoute value is created as computed so that it’s automatically updated whenever the router object changes.

I haven’t found any documentation about useRouter hook, but if you’re using VSCode (I hope you are), you can control + click it to inspect it’s declaration. As you’ll see, there are plenty of exported methods and properties in it, including programmatic navigation (push, back, replace, etc). Hope that helps you to understand what we have done to check the current route.

Now all we need to do is include the Nav component in App.vue.

<template>
  <Nav />
  <router-view />
</template>
<script>
import Nav from "./components/Nav.vue";
export default {
  name: "App",
  components: {
    Nav
  }
};
</script>

One good change you’ll notice here is that Vue 3 doesn’t have the one root element limitation anymore (well done Vue team). The next step is to build the simplest of the the components …

The Ingredients Component

Our ingredients component will be composed by a filter text input, a text input and a Add button to add new ingredients and a table with a delete and update buttons. When you click the delete button, the ingredient will be gone, and when you click update the item will be deleted from the list and put in the text input, so the user can change it and reinsert it. Since we have more than one reactive value that need to be used in the template, we’ll use the reactive method to group them in one object. We could use the ref method too, but then we’d have to create them one by one. The other thing that would change is that we’d have to use the .value ref method to access it’s current value inside the setup method. With reactive we don’t need to do that.

Other things we need to create in the setup method is a computed method to put our filter to work and the add, remove and update methods. Easy peasy right ? So let’s create a Ingredients.vue file in our components folder and start coding:

<template>
  <section>
    <input type="text" v-model="data.filter" placeholder="Type something to filter the list" />
  </section>
  <section>
    <input type="text" v-model="data.newIngredient" placeholder="Title" />
    <button @click="add" @disabled="!data.newIgredient">Add</button>
  </section>
  <section>
    <template v-if="!data.ingredients.length">
      <h1>No ingredients found</h1>
    </template>
    <template v-else>
      <table>
        <thead>
          <tr>
            <th>Ingredient</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="ingredient in filteredIngredients" :key="ingredient">
            <td>{{ingredient}}</td>
            <td>
              <button @click="update(ingredient)">Update</button>
              <button @click="remove(ingredient)">Delete</button>
            </td>
          </tr>
        </tbody>
      </table>
    </template>
  </section>
</template>

<script>
import { reactive, computed } from "vue";
export default {
  setup() {
    const data = reactive({
      ingredients: [],
      filter: "",
      newIngredient: ""
    });
    const filteredIngredients = computed(() =>
      data.ingredients
        .filter(ingredient => !data.filter || iingredient.includes(data.filter))
        .sort((a, b) => (a > b ? 1 : a < b ? -1 : 0))
    );
    const add = ingredient => {
      if (
        !data.newIngredient ||
        data.ingredients.some(ingredient => ingredient === data.newIngredient)
      )
        return;
      data.ingredients = [...data.ingredients, data.newIngredient];
      data.newIngredient = "";
    };
    const update = ingredient => {
      data.newIngredient = ingredient;
      remove(ingredient);
    };
    const remove = ingredient =>
      (data.ingredients = data.ingredients.filter(
        filterIngredient => ingredient !== filterIngredient
      ));
    return {
      filteredIngredients,
      data,
      add,
      update,
      remove
    };
  }
};
</script>

As you’ve noticed, we’re changing the ingredients array in a immutable way, always attributing to it a new array instead of changing the current value. That’s a safer and always recommended way to work with arrays and objects to ensure reactivity works.

If you think in the next component we have to create, Recipes, maybe you’ll figure out we have a problem with the Ingredients component: the state is local and the recipes will be composed of ingredients, so we’ll have to figure a way to share the state between them. The traditional way of solving this is to use Vuex or maybe a Higher Order Component that controls the state and pass it as props to both components, but maybe we can solve this the Vue 3 way, using the composition api. So let’s move on and create our ...

Store

To create our store that will be responsible to control and share the application state, we’ll make use of the reactive and computed methods of the new composition api to create a hook that will return the current state and the methods used to update it. This hook will then be used inside the setup method of the component, like we did with the useRouter hook, and we’ll be good to go.

For this example we’ll control both lists (ingredients and recipes) in one reactive object. It’s up to you to do like this or maybe create separate files for each one. Enough talking, let’s code:

import { reactive, computed, watch } from "vue";
const storeName = "vite-recipe-book-store";
const id = () => "_" + Math.random().toString(36).substr(2, 9);
const state = reactive(
    localStorage.getItem(storeName)
        ? JSON.parse(localStorage.getItem(storeName))
        : {
              ingredients: [],
              recipes: [],
          }
);
watch(state, (value) => localStorage.setItem(storeName, JSON.stringify(value)));
export const useStore = () => ({
    ingredients: computed(() =>
        state.ingredients.sort((a, b) => a.name.localeCompare(b.name))
    ),
    recipes: computed(() =>
        state.recipes
            .map((recipe) => ({
                ...recipe,
                ingredients: recipe.ingredients.map((ingredient) =>
                    state.ingredients.find((i) => i.id === ingredient)
                ),
            }))
            .sort((a, b) => a.name.localeCompare(b.name))
    ),
    addIngredient: (ingredient) => {
        state.ingredients = [
            ...state.ingredients,
            { id: id(), name: ingredient },
        ];
    },
    removeIngredient: (ingredient) => {
        if (
            state.recipes.some((recipe) =>
                recipe.ingredients.some((i) => i.id === ingredient.id)
            )
        )
            return;
        state.ingredients = state.ingredients.filter(
            (i) => i.id !== ingredient.id
        );
    },
    addRecipe: (recipe) => {
        state.recipes = [
            ...state.recipes,
            {
                id: id(),
                ...recipe,
                ingredients: recipe.ingredients.map((i) => i.id),
            },
        ];
    },
    removeRecipe: (recipe) => {
        state.recipes = state.recipes.filter((r) => r.id !== recipe.id);
    },
});

As you saw from the code, we’re using the computed method inside the useStore function so that our ingredients and recipes arrays can not be updated from outside the store. In the recipes computed value we’re mapping the ingredients array to it’s ingredient object. This way we can store just the ingredients id and get the id and the name in our recipes list. The computed arrays are then sorted by name using the sort and localeCompare methods.

We’ve added a method (id) to generate an unique id to every ingredient and recipe, and created the name property in the addIngredient method to make ingredients an array of objects. Another important point is that the removeIngredient method checks if the ingredient is included in a recipe before removing it. This is important to keep our recipes safe.

Another bonus is the use of the watch method to make the store state persistent in the localStorage of the user’s browser and the initial configuration of the state as the localStorage saved data or an object with empty ingredients and recipes arrays. This kind of approach can be used to persist the data in an remote api too.

I think now we can move on and

Refactor Ingredients Component

Now that our store is ready, it’s time to refactor the ingredient component to use it. This can be easily achieved by replacing the data.ingredients array with our store’s ingredients array and rewriting the add, update and remove methods to use the store’s addIngredient and removeIngredient. Another thing we’ll change is to make reference to ingredient.name instead of just ingredient since now it’s an object with the id and name properties. Let’s do it:

<template>
  <section>
    <input type="text" v-model="data.filter" placeholder="Type something to filter the list" />
  </section>
  <section>
    <input type="text" v-model="data.newIngredient" placeholder="Title" />
    <button @click="add(data.newIngredient)" @disabled="!data.newIgredient">Add</button>
  </section>
  <section>
    <template v-if="!data.ingredients.length">
      <h1>No ingredients found</h1>
    </template>
    <template v-else>
      <table>
        <thead>
          <tr>
            <th>#</th>
            <th>Name</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="ingredient in filteredIngredients" :key="ingredient">
            <td>{{ingredient.id}}</td>
            <td>{{ingredient.name}}</td>
            <td>
              <button @click="update(ingredient)">Update</button>
              <button @click="remove(ingredient)">Delete</button>
            </td>
          </tr>
        </tbody>
      </table>
    </template>
  </section>
</template>

<script>
import { reactive, computed } from "vue";
import { useStore } from "../store";
export default {
  setup() {
    const store = useStore();
    const data = reactive({
      ingredients: store.ingredients,
      filter: "",
      newIngredient: ""
    });
    const filteredIngredients = computed(() =>
      data.ingredients.filter(
        ingredient => !data.filter || ingredient.name.includes(data.filter)
      )
    );
    const add = ingredient => {
      store.addIngredient(ingredient);
    };
    const update = ingredient => {
      data.newIngredient = ingredient;
      rmeove(ingredient);
    };
    const remove = ingredient => {
      store.removeIngredient(ingredient);
    };
    return {
      filteredIngredients,
      data,
      add,
      update,
      remove
    };
  }
};
</script>

Everything is working fine, now it’s time to move on to a more complicated component

The Recipes Component

Our recipes component will be composed of a form where you can add a recipe by entering the title and selecting the ingredients in a select input. This ingredients will be in a list with the delete button. For simplicity we’ll not implement a ingredient quantity in our recipe, but feel free to do it as an exercise. Besides this form, we’ll have the filter input and the recipes list that will work just as in the ingredients component but adding a view button to preview the recipe and it’s ingredients right below the table. It’s not much more complicated from what he already did in the ingredients component. Time to code:

<template>
  <section>
    <input type="text" v-model="data.filter" placeholder="Type something to filter the list" />
  </section>
  <section>
    <input type="text" v-model="data.newRecipe.name" placeholder="Name" />
    <br />
    <select v-model="data.newIngredient">
      <option value></option>
      <option
        v-for="ingredient in data.ingredients"
        :key="ingredient.id"
        :value="ingredient.id"
      >{{ingredient.name}}</option>
    </select>
    <button
      @click="addIngredient(data.newIngredient)"
      :disabled="!data.newIngredient"
    >Add Ingredient</button>
    <table>
      <thead>
        <tr>
          <th>#</th>
          <th>Name</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="ingredient in data.newRecipe.ingredients" :key="ingredient.id">
          <td>{{ingredient.id}}</td>
          <td>{{ingredient.name}}</td>
          <td>
            <button @click="removeIngredient(ingredient)">Remove</button>
          </td>
        </tr>
      </tbody>
    </table>

    <button @click="add(data.newRecipe)" :disabled="!canAddRecipe">Add Recipe</button>
  </section>
  <section>
    <template v-if="!data.recipes.length">
      <h1>No recipes found</h1>
    </template>
    <template v-else>
      <table>
        <thead>
          <tr>
            <th>#</th>
            <th>Name</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="recipe in data.recipes" :key="recipe.id">
            <td>{{recipe.id}}</td>
            <td>{{recipe.name}}</td>
            <td>
              <button @click="view(recipe)">View</button>
              <button @click="update(recipe)">Update</button>
              <button @click="remove(recipe)">Delete</button>
            </td>
          </tr>
        </tbody>
      </table>
    </template>
  </section>
  <section v-if="data.viewRecipe.name">
    <p>
      <strong>Name:</strong>
      {{data.viewRecipe.name}}
    </p>
    <p>
      <strong>Ingredients</strong>
    </p>
    <table>
      <thead>
        <tr>
          <th>#</th>
          <th>Name</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="ingredient in data.viewRecipe.ingredients" :key="ingredient.id">
          <td>{{ingredient.id}}</td>
          <td>{{ingredient.name}}</td>
        </tr>
      </tbody>
    </table>
    <button @click="hide">Hide</button>
  </section>
</template>

<script>
import { reactive, computed } from "vue";
import { useStore } from "../store";
export default {
  setup() {
    const store = useStore();
    const data = reactive({
      ingredients: store.ingredients,
      recipes: store.recipes,
      filter: "",
      newRecipe: { name: "", ingredients: [] },
      newIngredient: "",
      viewRecipe: {}
    });
    const filteredRecipes = computed(() =>
      data.recipes.filter(
        recipe => !data.filter || JSON.stringify(recipe).includes(data.filter)
      )
    );
    const add = recipe => {
      store.addRecipe(recipe);
      data.newRecipe = { name: "", ingredients: [] };
      data.newIngredient = "";
    };
    const update = recipe => {
      data.newRecipe = recipe;
      remove(recipe);
    };
    const remove = recipe => {
      store.removeRecipe(recipe);
    };
    const hide = () => {
      data.viewRecipe = {};
    };
    const view = recipe => {
      data.viewRecipe = recipe;
    };
    const canAddRecipe = computed(
      () => data.newRecipe.name && data.newRecipe.ingredients.length
    );

    const addIngredient = ingredient => {
      if (data.newRecipe.ingredients.some(i => i.id === ingredient)) return;
      data.newRecipe.ingredients = [
        ...data.newRecipe.ingredients,
        data.ingredients.find(i => i.id === ingredient)
      ];
    };
    const removeIngredient = ingredient =>
      (data.newRecipe.ingredients = data.newRecipe.ingredients.filter(
        i => i.id !== ingredient.id
      ));
    return {
      filteredRecipes,
      data,
      add,
      update,
      remove,
      hide,
      view,
      canAddRecipe,
      addIngredient,
      removeIngredient
    };
  }
};
</script>

The app is working well, but with a very ugly look. As homework you may add styles and implement the features that are described in the recipe app readme.

I’ll leave the final code shared in my github so you have something to start from.

Conclusion

As we can see, the composition api is very useful and easy to use. With it we can implement react hooks’ like functions to share data and logic between our components, besides other things.

Hope you all liked the article and maybe learned something useful to help you in the transition from Vue 2 to Vue 3.

See you next article.

Posted on by:

n0n3br profile

Rogério Luiz Aques de Amorim

@n0n3br

Well qualified FullStack Developer familiar with a wide range of programming utilities and languages.

Discussion

markdown guide
 

Thanks for the great article! I have played with Vite and Vue 3, but not that thoroughly. The simple store is awesome - really shows the power the composition api has.