DEV Community

Cover image for Vue3 Navigation, State Management and Form handling
prodbyola
prodbyola

Posted on

Vue3 Navigation, State Management and Form handling

This is the second part of a series of posts An Easy and Comprehensive Guide to Vue3. The goal is to provide a gentle pratical introduction to Vue3. You would find the first part here. You need to read this first part. You can also find the project's source code here.

Layout

In the first part of this tutorial, our App.vue contains a RouterView component. We need to build a layout around this view. Layout components are components shared across multiple pages in our app.

App Header

First let's create an AppHeader component.

  • Create a folder src/components/common and component src/components/common/AppHeader.vue.
  • Add this code to AppHeader.vue file:
<template>
  <header>
    <h3 class="app_title">todo</h3>
    <div class="add_icon">
      <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
        <path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z" />
      </svg>
    </div>
  </header>
</template>

<style lang="scss" scoped>
  $add_icon_size: 38px;

  header {
    display: flex;
    width: 100%;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 2rem;

    .app_title {
      font-weight: bold;
      font-size: 1.64rem;
    }

    .add_icon {
      svg {
        height: $add_icon_size;
        width: $add_icon_size;
        fill: rgb(100, 99, 99);
      }
    }

    .add_icon,
    .app_title {
      cursor: pointer;
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode
  • Now import and use AppHeader component in App.vue:
<template>
  <AppHeader />
  <RouterView />
</template>
<script setup lang="ts">
  import AppHeader from './components/common/AppHeader.vue'
</script>
Enter fullscreen mode Exit fullscreen mode

Vue layout

App Sidebar

It's time to add Sidebar to our app layout. The sidebar will display a list of available categories for our tasks. So let's create a TodoCategory component:

  • Create src/components/TodoCategory.vue and add the following code:
<template>
  <div class="app_category">
    <div class="cat_color"></div>
    <p class="cat_title" v-if="title">{{ title }}</p>
  </div>
</template>

<script lang="ts" setup>
  defineProps<{
    title?: string
    color: string
  }>()
</script>

<style lang="scss" scoped>
  .app_category {
    display: flex;
    column-gap: 16px;
    align-items: center;
    cursor: pointer;

    .cat_color {
      width: 24px;
      height: 24px;
      border-radius: 50%;
      background-color: v-bind('color');
    }

    .cat_title {
      font-size: 0.86rem;
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Notice how we pass data from our component's props to our style section using v-bind? This is an interesting vue feature. You can directly pass data from Javascript to CSS without losing the naturality of your component structure!

  • Create the sidebar component in src/components/common/AppSidebar.vue and add this code:
<template>
  <div class="app_sidebar">
    <TodoCategory
      v-for="(cat, index) in categories"
      :key="index"
      :color="cat.color"
      :title="cat.title"
    />
  </div>
</template>

<script setup lang="ts">
  import TodoCategory from '../TodoCategory.vue'

  const categories = [
    { title: 'work', color: 'rgba(137, 43, 226, 0.308)' },
    { title: 'study', color: 'rgb(117, 242, 250)' },
    { title: 'entertainment', color: 'rgb(247, 147, 148)' },
    { title: 'family', color: 'rgb(184, 255, 179)' }
  ]
</script>

<style>
  .app_sidebar {
    display: flex;
    flex-direction: column;
    row-gap: 24px;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

With this code, we create a list of categories and iteratively display them using TodoCategory component.

  • Now let's update our App.vue component to adjust our layout and import AppSidebar:
<template>
  <AppHeader />
  <main class="app_main">
    <AppSidebar class="app_sidebar" />
    <div class="app_content">
      <RouterView />
    </div>
  </main>
</template>
<script setup lang="ts">
  import AppHeader from './components/common/AppHeader.vue'
  import AppSidebar from './components/common/AppSidebar.vue'
</script>
<style lang="scss" scoped>
  .app_main {
    display: flex;

    .app_sidebar {
      width: 20%;
    }

    .app_content {
      flex: 1;
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Vue layout

Our dynamic page contents will be handled and displayed by RouterView while AppHeader and AppSidebar will remain static throughout the app. Let's proceed to the next section to see how this works.

Navigation

VueJs uses vue-router to manage navigation within our apps. Let's create another page and programatically navigate between two pages.

  • Create a new file component, a view where we'll be adding new tasks to our app src/views/CreateTaskView.vue, then add the following code. We'll update the code later but let's take this as a new page:
<template>
  <h3>Create Task Page</h3>
  <p>This page will be used to create new tasks.</p>
</template>
Enter fullscreen mode Exit fullscreen mode
  • Now update your src/router/index.ts file to import CreateTaskView and map it to a router path:
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import CreateTaskView from '@/views/CreateTaskView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView
    },
    {
      path: '/create-task',
      name: 'create-task',
      component: CreateTaskView
    }
  ]
})

export default router
Enter fullscreen mode Exit fullscreen mode
  • Now go to src/components/common/AppHeader, create a script section like below and update your template code:
<template>
  <header>
    <h3 @click="goToRoute('home')" class="app_title">todo</h3>
    <div @click="goToRoute('create-task')" class="add_icon">
      <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
        <path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z" />
      </svg>
    </div>
  </header>
</template>

<script setup lang="ts">
  import router from '@/router'

  const goToRoute = (name: string) => router.push({ name })
</script>
Enter fullscreen mode Exit fullscreen mode

Firstly, in the script tag, we create a new function goToRoute that accepts the destination route's name as parameter. If you go back and check src/router/index.ts, you would notice we have a name property on each defined route. You can pass a name as part of the options for router.push() and vue will take you to the destination.

Secondly, we added Click Event to both app_title and add_icon elements which respectively call goToRoute and supply the destination route names.

Now save your project, click on add_icon and you'll see this takes you to CreateTaskView. Click on app_title and it will take you back to the HomeView.

Vue Router Navigation

Local State Management

Before we build out the form for creating a new task, let's do a quick introduction to state management in VueJs. Think of a state as a piece of data which when updated, its changes is reflected in the UI.

Ref API

We can use vue's ref to convert a Javascript value to a vue state. As an example, let's update our CreateTaskView with this code:

<template>
  <h3>Create Task Page</h3>
  <p>My state {{ count }}.</p>
  <button @click="increment">Increment</button>
</template>

<script setup lang="ts">
  let count = 0
  const increment = () => {
    counter++
    console.log(counter)
  }
</script>
Enter fullscreen mode Exit fullscreen mode

We create a variable count and function increment for incrementing count variable. Additionally, we log the current value of count. In the template, we display the value of count in increment and then add a button which calls increment everytime it's clicked.

Navigate to http://localhost:5173/create-task (if you're not there already) and click on the Increment button. Nothing seems to be happening right? If you open your browser's inspection tool and check the console tab however, you'll notice the value of count is continuously updated as you click on the button. The changes is not reflected on the UI because you didn't "mark" the count value as a state.

Now let's update the script section of our code:

<script setup lang="ts">
  import { ref } from 'vue'

  let count = ref(0)
  const increment = () => {
    count.value++
    console.log(count)
  }
</script>
Enter fullscreen mode Exit fullscreen mode

We wrap the value of count in vue's ref function. This marks count as a state whose changes should reflect on the UI. To access the actual value of count within our Javascript code, we need to get the value property this way count.value. This is because ref(data) returns a Ref<T> object where T is the type of data. To access or update the actual data value, you need to get the value property. Now click the Increment button again and you'll see your changes reflecting.

Notice that calling the value property is not required within the template.

Reactive API

What if a state is an object with many properties? Calling obj.value.property to access/update each property may seem ineffecient. Let's demonstrate how using reactive instead of ref resolves this issue. Update your CreateTaskView with this code:

<template>
  <h3>Create Task Page</h3>
  <p>Your name is {{ person.name }}.</p>
  <p>Your age is {{ person.age }}.</p>
  <button @click="update">Update Person</button>
</template>

<script setup lang="ts">
  import { reactive, ref } from 'vue'

  const restore = ref(false) // whether to restore `person` state to default value

  const defaultDetails = {
    name: 'guest',
    age: 0
  }

  let person = reactive(structuredClone(defaultDetails))

  const update = () => {
    if (restore.value) {
      person.name = defaultDetails.name
      person.age = defaultDetails.age

      restore.value = false
      return
    }

    person.name = 'Falola'
    person.age = 54
    restore.value = true
  }
</script>
Enter fullscreen mode Exit fullscreen mode

This code attempts to toggle between two person object values. When restore is true, we set person to defaultDetails. Otherwise, we update the properties of person with some values. Your real-world implementation may be simpler. For example, we had to create a deep clone of defaultDetails (using Javascript's structuredClone) because we need to use its original data to restore the value of person. The main lesson here is that we're able to access/update the properties of person state in the same way we would access/update a normal Javascript object.

V-Model

All the state changes we've observed so far are one-directional. They're based on changes triggered by our Javascript code. That is, changes only flow from script to template. What if we want to update a state based on, say user-input? Vue's v-model helps us bind user input fields to Javascript state, without the need to manually watch and update input values through events. Let's see v-model in action. Replace CreateTaskView with the following code:

<template>
  <h3>Create Task Page</h3>
  <p>Your name is {{ person.name }}.</p>
  <p>Your age is {{ person.age }}.</p>
  <input placeholder="Name" v-model="person.name" />
  <input placeholder="Age" v-model="person.age" type="number" />
</template>

<script setup lang="ts">
  import { reactive } from 'vue'

  let person = reactive({
    name: 'guest',
    age: 0
  })
</script>
Enter fullscreen mode Exit fullscreen mode

Now we have two input fields, each binded to the properties of person state and changes made through the fields are automatically reflected in our template.

Vue v-model and data binding

Creating Forms

With our understanding of vue's ref, reactive and v-model we can now build our form for creating new tasks.

Sharing Modules

First let's create a module that will hold data model shared across our app.

  • Create a new typescript file at src/shared.ts
  • Now go to src/components/common/AppSidebar.vue and cut (instead of copy) the categories array that we created earlier. Paste and export the categories array in the newly created shared.ts file:
export const categories = [
  { title: 'work', color: 'rgba(137, 43, 226, 0.308)' },
  { title: 'study', color: 'rgb(117, 242, 250)' },
  { title: 'entertainment', color: 'rgb(247, 147, 148)' },
  { title: 'family', color: 'rgb(184, 255, 179)' }
]
Enter fullscreen mode Exit fullscreen mode
  • Now you can use the categories array anywhere in your app by import. Let's import it back into AppSidebar.vue:
<!-- Leave your template -->
<script setup lang="ts">
  import { categories } from '@/shared'
  import TodoCategory from '../TodoCategory.vue'
</script>
<!-- Leave your style -->
Enter fullscreen mode Exit fullscreen mode
  • At this point, it's cool to rename our categories array to tags. If you're using VSCode, go to src/shared.ts, move your cursor on top of categories variable declaration and press F2 key. Rename categories to tags. This will rename the variable in all the imports.

  • Another thing we need to do is create and export a TaskType model that we'll use to define our task later. Add this typescript type declaration to shared.ts module after tags array:

export type TaskType = {
  title: string
  description: string
  done: boolean
  tags: string[]
}
Enter fullscreen mode Exit fullscreen mode
  • Your full shared.ts should now look like this:
export const tags = [
  { title: 'work', color: 'rgba(137, 43, 226, 0.308)' },
  { title: 'study', color: 'rgb(117, 242, 250)' },
  { title: 'entertainment', color: 'rgb(247, 147, 148)' },
  { title: 'family', color: 'rgb(184, 255, 179)' }
]

export type TaskType = {
  title: string
  description: string
  done: boolean
  tags: string[]
}
Enter fullscreen mode Exit fullscreen mode
Create Custom Field and Button

We need to create a custom reusable input field for our form.

  • Create a new SFC at src/components/common/InputField.vue and add the following template and script:
<template>
  <div class="app_field">
    <h3 class="field_label">{{ label }}</h3>
    <textarea v-if="type === 'textarea'" v-model="model" :placeholder="placeholder"></textarea>
    <input v-else :type="type" v-model="model" :placeholder="placeholder" />
  </div>
</template>

<script setup lang="ts">
  defineProps<{
    label: string
    type?: string
    placeholder?: string
  }>()

  const model = defineModel({ type: String })
</script>
Enter fullscreen mode Exit fullscreen mode

As you may have noticed, we render a textarea tag or an input tag, based on whether the type prop of our component is "textarea" or any other string. In other words, if the prop is "textarea", we show a textarea tag else, we show input tag. Notice how we use v-if and v-else to handle this condition.

Another thing you would notice is how we use vue's defineModel API (instead of ref) to create a model variable which we then pass to our input and textarea v-model. We use defineModel here because we want our InputField component to emit its changes to its parent component, so any parent can collect changes to model by binding their own variable to InputField's v-model. We'll see all of this in action very soon.

  • Now add the following style to InputField:
<style lang="scss" scoped>
  .app_field {
    width: 100%;
    margin-bottom: 32px;

    .field_label {
      font-weight: 500;
      margin-bottom: 8px;
    }

    input {
      height: 42px;
    }

    textarea {
      height: 126px;
    }

    input,
    textarea {
      width: 100%;
      outline: none;
      border: none;
      background-color: rgb(241, 240, 240);
      border-radius: 8px;
      padding: 8px 16px;
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode
  • Let's create a custom button for our app. Create an SFC at src/components/common/AppButton.vue and paste the following code:
<template>
  <button>{{ text }}</button>
</template>

<script setup lang="ts">
  defineProps<{
    text: string
  }>()
</script>

<style lang="scss" scoped>
  button {
    border: none;
    outline: none;
    color: white;
    background-color: rgb(100, 99, 99);
    height: 42px;
    width: 100%;
    border-radius: 6px;
    cursor: pointer;
  }
</style>
Enter fullscreen mode Exit fullscreen mode
Build New Task Form

It's time to build out our new task form.

  • Clear everything we've written in src/views/CreateTaskView.vue and add the following template:
<template>
  <div class="create_task">
    <div class="create_task__form">
      <h2 class="form_title">Create New Task</h2>
      <InputField label="Title" v-model="newTask.title" placeholder="add a title..." />
      <InputField
        label="Description"
        v-model="newTask.description"
        placeholder="add a description..."
        type="textarea"
      />
      <div class="tag_list">
        <TodoCategory
          v-for="(cat, index) in categories"
          :key="index"
          :title="cat.title"
          :color="cat.color"
          class="task_tag"
          :class="{
            active: newTask.tags.includes(cat.title)
          }"
          @click="() => toggleTag(cat.title)"
        />
      </div>
      <AppButton @click="addTask" class="action_btn" text="Add Task" />
      <div v-if="tasks.length" class="task_list">
        <div v-for="(task, index) in tasks" :key="index" class="task_card">
          <p>{{ task.title }}</p>
          <p @click="deleteTask(index)" class="del">Delete</p>
        </div>
      </div>
    </div>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This template uses our custom InputField twice - one for text field and another for textarea. In both cases, we're binding the field's input to a property of newTask object. Now you should understand why we used defineModel in our InputField earlier. Using defineModel makes it possible for InputField to emit changes to model on the fly.

  • Now let's add our script section. Please study the code and see if you could learn some things:
<script setup lang="ts">
  import { reactive } from 'vue'
  import type { TaskType } from '@/shared'
  import { tags } from '@/shared'

  import InputField from '@/components/common/InputField.vue'
  import TodoCategory from '@/components/TodoCategory.vue'
  import AppButton from '@/components/common/AppButton.vue'

  const defaultTask: TaskType = {
    title: '',
    description: '',
    done: false,
    tags: []
  }

  let newTask = reactive(structuredClone(defaultTask))

  const tasks = reactive<TaskType[]>([])

  const isEmpty = (v: string) => v.length === 0

  const addTask = () => {
    // validate input
    if (isEmpty(newTask.title) || isEmpty(newTask.description)) {
      return
    }

    tasks.splice(0, 0, newTask)
    newTask = reactive(structuredClone(defaultTask))
  }

  const deleteTask = (i: number) => tasks.splice(i, 1)

  const toggleTag = (tag: string) => {
    const f = newTask.tags.find((t) => tag === t)
    if (f) {
      const i = newTask.tags.indexOf(f)
      newTask.tags.splice(i, 1)
    } else {
      newTask.tags.push(tag)
    }
  }
</script>
Enter fullscreen mode Exit fullscreen mode
  • Lastly, let's style our form:
<style lang="scss" scoped>
  .create_task {
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: center;

    .create_task__form {
      width: 50%;
      display: inline-flex;
      flex-direction: column;
      align-items: center;
      max-width: 440px;

      .form_title {
        font-weight: bold;
      }

      .tag_list {
        display: inline-flex;
        column-gap: 24px;

        .task_tag {
          padding: 4px 8px;
        }

        .task_tag.active {
          background-color: rgb(218, 218, 218);
          border-radius: 4px;
        }
      }

      .action_btn {
        margin-top: 36px;
        float: right;
        width: 180px;
      }

      .task_list {
        width: 100%;
        margin-top: 32px;

        .task_card {
          display: inline-flex;
          width: 100%;
          padding: 16px 12px;
          border-bottom: 1px solid rgb(201, 201, 201);
          justify-content: space-between;
          align-items: center;

          .del {
            color: red;
            font-size: 0.86rem;
            cursor: pointer;
          }
        }
      }
    }
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Vue Form Handling

In our next lesson, we'll learn about global state management in VueJs. If you would like to connect, please contact me.

Top comments (0)