In this inserted article, we take a closer look at selected fundamental concepts of the Vue.js framework that Nuxt builds upon. I believe it is not a good idea to use any tool without at least a rough understanding of how it works under the hood. Before we continue exploring more of Nuxt’s great features, we’ll go back to the roots without building anything specific here. But you will end up better prepared when you try it yourself later.
To begin, note the best available source of Vue information - the official documentation. You will learn much more about everything mentioned in this article there, and you can always return to consult questions and issues.
Now for the promised overview of the basics. First, we return to components, which we briefly described earlier, and go a bit deeper now.
Components
The basic building block of a Vue application is a component. By composing and nesting them you create the required functionality. By far the most common (though not the only possible) way to define a component is the SFC – single file component. As the name suggests, it combines all UI parts and logic into a single file identifiable by the .vue extension.
Vue’s built-in compiler can interpret these files and, during the application build, translate them into fully functional HTML + JavaScript. This means that if we want to use them, we must include a build phase during which the transformation happens. I mention this because Vue can also be used statically via a simple standalone script inclusion in an HTML page. But this effectively limits you to use only bits of Vue here and there. However, I already tricked you into adopting the build step from the very beginning with the first pnpm dev command. That’s why we could use SFCs without worrying about anything.
An SFC consists of three main parts:
- Template - an HTML‑like template that defines the final appearance of the rendered page
- Script - a section for JS/TS code that drives rendering logic, backend communication, and coordination with other parts of the app
- Style - a place for defining CSS styles
It is not strictly necessary to include all three. A valid component can contain only <script> (a “renderless” or functional component) or only <template> if you don’t need more substantial data preparation or manipulation. A standalone <style> is not allowed because it wouldn’t be clear what the styles belong to.
I prefer having <template> first, <script> second and <style> last (if present). I found out I swim against a current with this habit, because you'll probably see <script> before <template> more often, but it just feel more natural - first I want to see how the component will look like and only then what values will it display. Data without context seem less useful for me. Anyway, it is a matter of personal preference. The only guideline set is not to mix the order up within a project. Pick just one style and follow it everywhere.
It’s also theoretically possible to define arbitrary custom blocks. Vue lets you declare them, but you must handle them yourself. While I haven’t needed them in practice yet, a good example is an <i18n> block in the eponymous library for facilitating webapp localization.
SFCs are primarily a way to keep all logically connected parts of a certain functionality in one place. This is somewhat at odds with the more traditional (at least for me) separation of concerns. However, in my recent experience it makes much more sense and is much easier to work with than constantly jumping between multiple files. If that’s hard to accept, Vue has sort of a workaround - using the src attribute you can import block contents from other files. I still don’t recommend it. If your .vue files feel bloated, rather consider splitting them into several smaller components.
Now let’s look inside each base block:
Template
Inside the <template> tag is the definition of the content of the future rendered (part of the) page. You write code very similar to HTML, but you can use certain special Vue syntactic features:
- Double curly braces (the “mustache” syntax) allow inserting JavaScript -
{{ msg }}. During template compilation, the expression is evaluated and interpolated as plain text, and the evaluated current value appears in the rendered HTML. Typically, you want to evaluate only simple variable references or function calls. Technically you are not limited, but it’s considered a good practice to keep the template clean and push heavy JS work into the<script>section or other files - you already know Nuxt allows you to reference them directly thanks to auto‑imports. - A colon
:before an attribute name (native or your own - see props section later) is shorthand forv-bind:and binds the attribute to a JavaScript expression. The same advice as above applies - don’t abuse templates for complex inline logic. - An
@before an event name is shorthand forv-on:and binds a handler function. When the specified event is captured (native likeclickorchange, or custom - see emits later), the given function is called. The function can be written inline, but it’s again better to reference a callback defined in the appropriate place. - Other
v-*directives are pseudo‑attributes that give the compiler instructions for special handling. For example:-
v-if- renders the element if the condition is met -
v-else- renders the element if the condition is NOT met -
v-show- always render, but show the element only if the condition is met -
v-for- render multiple items from a list in a loop -
v-html- insert HTML‑styled content into an element (beware, potentially dangerous) - complete list is HERE
- you can also define your own
-
Vue templates also have the ability to understand references to other components. Use the .vue filename in PascalCase notation as an identifier. In plain Vue, components must be registered before use. Nuxt handles this automatically as long as .vue files are in the /app/components folder (or you configure directorsies where auto‑import should happen).
Thanks to this, you can compose larger functional units from individual SFCs. Vue also fuels the simple, yet powerful two-way communication. We’ll show how it works in a moment.
Script
Because Vue values flexibility, the following is not the only option, but in my opinion there is currently no better approach than wrapping your JavaScript/TypeScript logic using:
<script setup lang="ts">
// your JS/TS logic
</script>
The tag name is obvious. The setup attribute tells the compiler that we will use Vue’s Composition API. I’ve kept quiet until now that another variant exists, but the Options API is, in my view, an obsolete approach not worth learning first. You may come across guides, libraries and legacy codebases that still use it, but there’s no need to burden yourself until you actually need it. I’m confident you won’t regret using <script setup>. All the Vue/Nuxt features mentioned later implicitly assume using it.
The lang="ts" attribute denotes a preprocessor that enables TypeScript support. It isn’t mandatory, but without TypeScript and its static analysis I can’t really imagine my development. Properly configured TypeScript in your IDE immediately warns you about accessing undefined variables, passing wrong argument types, or calling functions that don’t exist - and much more. It’s worth investing a bit of effort up front to understand its principles.
Any JS variables and functions you define inside <script setup> are automatically available to the component template. They are also safely sealed off from the outside unless you explicitly expose them using defineExpose macro. I recommend keeping this block short - declare only elements tightly bound to the current component and refactor longer sequences into separate utils or composables (see the earlier tutorial part). Once we introduce the last section, we’ll get to more concrete examples.
Style
The <style> tag wraps the CSS definition. If you want more than plain CSS, you can use the lang attribute to define a preprocessor (e.g., Sass), but you must first configure it in your project.
It’s a good practice to keep styles inside an SFC local to that component and avoid letting them leaking into the rest of the app. That can have unexpected consequences elsewhere and the source of such bugs is hard to find. Vue makes file‑level encapsulation easy - just add the scoped attribute: <style scoped> - and you’re done. Styles meant to apply globally should be defined at the top level in app.vue, or, if there are more of them, in a separate CSS file loaded via Nuxt config.
You can define multiple <style> blocks in a single component, but I think it’s not very practical. I also haven’t used CSS Modules much.
I would say you’ll likely start relying on this block first, but then you shift towards using it more sparingly - only for specific requirements. Appearance is often handled by UI libraries, and there’s no need to reinvent the wheel. Or if you reach for Tailwind CSS (there will be a later tutorial part about it), classic CSS almost disappears for you…
Passing Data Between Components
Encapsulating parts of the app into separate components is great, but they must also communicate. Vue provides support for this indeed.
Props
With the special defineProps() function, you can define a set of variables that can be passed into the component from outside. Conceptually this corresponds to public attributes of a class in object‑oriented languages. To be precise, this is not a real function but another compile‑time macro whose contents are transformed when translating the .vue file into real code.
The macro argument is an object of declared properties that Vue refers as props. There are several syntax variants. For a long time I liked so-called object syntax, because it looks natural:
const props = defineProps({
foo: { type: String, required: true },
bar: { type: Number },
})
The downside is that for more complex type you need to re-type it via generic Vue utility PropType:
const props = defineProps({
foo: { type: String, required: true },
bar: { type: Number },
ext: { type: Object as PropType<CustomType>},
})
This is not very intuitive and it is unnecessarily complex. Because of that I finally adopted newer type based syntax. It looks weird, but it is more suitable and much shorter:
const props = defineProps<{
foo: string,
bar?: number,
ext?: CustomType,
}>()
You can then use the defined values inside <script> as props.foo and props.bar. In <template> expressions, you can refer to foo or bar directly; the compiler is smart enough to resolve that they are props.
Passing into the child from one level up looks like this:
<template>
<ChildComponent foo="foo" :bar="2" />
</template>
Thanks to TypeScript, type checking works, so foo only accepts strings and bar only numbers. Note that text can be passed as a normal HTML attribute value, while all other types must be prefixed with a colon (shorthand for v-bind:).
Also remember that you should treat props as read‑only inside a component. If you mutate them, you tightly couple parent and child, which undermines encapsulation and reusability. You can use a prop value to initialize your own local variable (beware of pass‑by‑reference with objects). Usually it’s even better to use v-model or state management (see below).
Emits
That covered passing data into a component; now we need the other direction. Use the defineEmits() macro to declare custom events that a component can emit. In the simplest case, pass an array of event names. In the template, trigger an event by passing its name to $emit:
<button @click="$emit('event')"></button>
In <script>, as with props, refer to the defineEmits result:
const emit = defineEmits(['event'])
function buttonClick() {
emit('event')
}
In the parent component, listen with @ (shorthand for v-on:) before the declared event name. When the child emits event, the parent’s foo method is called:
<template>
<ChildComponent @event="foo()" />
</template>
To pass data upward along with the fact that something happened, events can declare payloads whose values are sent with them. Details here: typing component emits.
Provide/Inject
Passing props works well when the component tree is flat. As components nest deeper, you may find you are passing some props only to forward them further down, until finally a deep child uses them. This can lead to the anti‑pattern known as prop drilling. The result is a tightly coupled structure that is hard to maintain because a change in internal implementation forces you to adjust prop definitions in many intermediate components.
Vue sidesteps this with provide and inject. Using provide() you can declare that a component exposes some data to all its descendants:
provide('message', 'foo')
Provided the component is in the ancestor chain, a descendant can request the data with inject():
const message = inject('message')
You can also provide data globally at the application level using app.provide().
While this distribution mechanism may seem useful at first; in my experience I’ve rarely used it. In practice I find state‑management libraries work better (see below). It’s good to know the option exists, though.
We don’t have a similar problem with emits because events naturally bubble up the DOM tree and it’s up to you where to catch them. Additionally, Vue lets you play with event behavior via modifiers.
A brief comment on the DOM: it stands for Document Object Model, the internal representation of an HTML document - tags, their attributes, and content. By nature it’s a tree-like structure from the root element down to the deepest descendants. A lightweight JavaScript variant is the virtual DOM, and because manipulating its objects is much faster and more efficient than touching the real DOM, Vue and other frameworks primarily work with it and only then project the result into actual HTML for rendering.
v-model
A common scenario in interactive apps is a component that accepts an initial value, lets the user work with it, and notifies the parent when it changes. A typical example is a form input.
The v-model directive in combination with the defineModel macro provides a more straightforward definition for this. You can create a simple Child.vue component like this:
<template>
<input v-model="model" />
</template>
<script setup>
const model = defineModel()
</script>
And call it just as simply from Parent.vue:
<template>
<Child v-model="foo" />
</template>
Vue automatically ensures that user input updates foo; there’s no need to wire extra handlers. Another nice simplification of routine tasks.
State management
The mechanisms above work fine for simpler apps. As the number of components and interactions grows, constantly thinking about where to wire connections becomes annoying. In such cases, raise the problem a level up and keep the state in one place across the entire application.
Nuxt provides the useState composable, which is sufficient for less complex data on its own. The de facto standard for state management in Vue is currently Pinia, which makes it easy to create, maintain, and safely use global state anywhere in a Vue (Nuxt) app. More on this later in a separate article.
Reactivity
Vue’s true power appears when you start working with dynamically changing data. For example, after clicking a button, you want the “number of clicks” value to update. You register a click listener, increment a variable… and then what? How to force a redraw on the screen? Should you reach into the DOM and edit an element’s text content? None of that - in Vue it just “happens” thanks to reactivity.
In the Vue world, reactivity means you can create special wrapper objects around data that automatically track value changes and react by propagating them to every place where the value is used. Different frameworks solve this general notification problem differently. Vue offers the Reactivity API that makes it straightforward.
We won’t dive deep into the technical background here. If you’re interested, continue with Reactivity in depth in the docs. For now, three functions suffice:
-
ref()- takes a primitive/object/array and wraps it so the return value is reactive. Wherever you use it, it updates in the future whenever it changes. Such objects (refs) can be used in templates, passed as function arguments or even as props to components and carry their reactivity with them. The small cost is writingname.valuewhen used in scripts to access the inner value. Inside<template>,namesuffices because the compiler fills in.value. -
computed()- takes a callback function that automatically re‑runs if a reactive change is detected inside - i.e., when some value the system tracks changes. This defines dynamic computations. For example, for a simple adder, you can define tworef()values bound to user input, and their sum iscomputed(() => a.value + b.value). Whenever the user changes one of the inputs, the result re‑computes immediately. -
watch()- allows observing a reactive value and performing a side effect whenever it changes. The first argument determines the value to watch; the second is the callback to run on change. For example, you could track the count of failed login attempts and, after exceeding a limit, set a variable that disables the “Log in” button.
That was a whirlwind tour. The reactivity system is, of course, more complex; more functions exist and offer various options. You can read them as needed. Even with these three basics, you can cover many scenarios.
It’s more worth pointing out a pitfall now. In my experience, the ease of use tends to tempt overuse in places where it’s not needed. As an application grows, especially with computed() and watch(), more and more re‑evaluations happen and a seemingly small change can cause a cascade of calls, not to mention the subsequent manipulations of the DOM. Vue cleverly batches reactive updates and optimizes DOM work, but background load still grows. It also becomes difficult to trace update flows when debugging hidden changes that shouldn’t occur.
To make the problem worse, at first you might not even realize there is any. All reactivity operations run quietly in the background and, because Vue is well optimized for performance, you won’t notice anything wrong in simple apps. When it starts catching up with you, refactoring and optimizing can be quite hard already. It’s better to approach reactivity skeptically from the start - don’t ask “what can I make reactive”, but “what doesn’t need to be reactive”, because it won’t actually change, or it can be solved otherwise than adding computed()/watch().
Still, it’s a very powerful tool and an integral part of a Vue developer’s arsenal. Just beware the Man with a Hammer syndrome.
Component lifecycle
Reactive or not, a Vue component instance is not a static entity. It goes through several phases of its virtual life. Very briefly and simply:
1) When the compiler determines a new instance should be created, it first runs the code inside <script setup>. Variables are created and initial reactive state is set.
2) The mount phase begins, when the necessary HTML structure is created inside the virtual DOM that Vue uses to control what ultimately gets rendered. The finished component is then displayed.
3) The mounted component can enter the update phase if a relevant reactive state change is detected - values are recalculated, the virtual DOM is patched, and the HTML is re‑rendered.
4) The component is unmounted because different content is rendered, navigation occurs, or the app exits.
5) The component instance ceases to exist.
Before and after each mount, update, and unmount phase, you can register a callback that Vue runs every time it reaches that point. Use the special lifecycle hooks functions onBeforeMount(), onMounted(), etc. This can be useful if, for example, you want to show a message that page initialization completed or release resources after the component is done.
Beware of two things:
- Async functions are not awaited here. If needed, put
awaitdirectly in<script setup>. An async function insideonBeforeMount()does not guarantee that DOM mounting starts only after it finishes. - Don’t place hook registration inside an asynchronous block (e.g., within
setTimeout). As soon as<script setup>finishes synchronously, Vue loses the current component instance context, so there is nothing to attach to.
More information, including a clear diagram, can be found HERE.
Summary
This article is already quite long, even though we only skimmed the surface of most topics and didn’t start many others. I repeat my tip from the beginning - read the Vue documentation, where you’ll find much more. There is, of course, an extensive documentation for Nuxt too.
At this point, you should be armed with enough knowledge about Vue and the Nuxt framework to start building larger, more realistic apps yourself. However, doing everything from scratch could be unnecessarily time‑consuming. In the next part of the tutorial, we’ll show how to integrate elements of selected UI libraries with little effort and start using them right away.
Top comments (0)