Written by Sebastian Weber✏️
The goal of this article is to offer React developers a quick introduction to Vue development. It's structured as a guide, recreating common React use cases and demonstrating how they are implemented with Vue. Not every aspect is explained in detail, but corresponding React examples are provided for every Vue code snippet in this GitHub repo.
Table of contents
- What we’ll cover
- Single-file components in Vue vs. JSX in React
- Using the Composition API
- Conditional rendering in templates with Vue directives
- Rendering JavaScript via text interpolation
- Multi-root components with Vue fragments and fallthrough attributes
- Accessing DOM nodes with template refs
- Working with props in Vue 3
- Vue slots: Rendering children or props
- Working with Vue’s Reactivity API and two-way data binding
- Using Vue’s composables as React Hook counterparts
- Rendering and component lifecycles with Vue composables
What we’ll cover
This article is for React devs who are familiar with creating projects purely with functional components and Hooks, but who are interested in working with Vue 3. Basic TypeScript know-how is helpful but not required.
I have over five years of experience with React and many thoughts went into what would most assist an experienced React dev wanting to learn Vue quickly. So, in this post, we’ll only work with client-side Vue and the Composition API with TypeScript and compiler macros.
We’ll also be using single-file component (SFC) .vue
files and the following script
"flavor": <script setup lang="ts">
. In React, these would be similar to .jsx
/.tsx
files, but are different because our template, logic, and styles are all contained in one place.
Here’s what we won’t cover in this piece:
- "Legacy" Vue variants (e.g., using the Options API)
- "Exotic" methods of creating Vue components (e.g., using JSX with Vue)
- No global state management (i.e., no Pinia, Redux, etc.)
- No routing or other advanced concepts — just the core concepts of Vue
- No styling of Vue components besides the foundations
About our companion React and Vue projects
In preparation for this article, I built identical to-do apps, one each with React and Vue 3. You can find both apps in this GitHub repository in their dedicated folders:
companion-project-vue3-for-react-devs/
├─ react/
├─ vue/
For the sake of brevity and clarity, we’ve only directly included Vue code snippets in this article, but links to the corresponding React snippets in the GitHub project are linked below each Vue snippet.
Tooling support
Like with JSX in React components, you’ll need tooling support from your editor to work with .vue
files. IntelliJ and WebStorm both support .vue
files out of the box, but the recommended way to use Vue with the Composition API and TypeScript in VS Code is to use the extension Volar with its TypeScript support.
It's also helpful to use ESLint in Vue projects. I believe that it also helps you learn a new framework. The official ESLint plugin for Vue is eslint-plugin-vue
, which works with .vue
files.
If you want, you can read about setting up a ESLint and Prettier workflow with automatic code reformatting on save when using eslint-plugin-vue
to enforce recommended Vue rules.
Single-file components in Vue vs. JSX in React
An SFC typically consists of three sections: script
, template
, and style
. If you don't need component styling, you can omit the style
tag. The order of these sections is up to you. I prefer to put template
before script
, but the official Vue documentation recommends using script
first.
The template
section is where you put your markup; it is the Vue counterpart to JSX files in React's component architecture. While Vue does support using JSX with render functions, this variant is not commonly used or recommended by the community.
The following code snippet of the Headline
component shows the typical structure of a SFC:
<!-- Headline.vue -->
<template>
<!-- the JSX "pendant" of React -->
<h1 :style="{ color }">{{ text }}</h1>
</template>
<script setup lang="ts">
/*
Here comes the component logic. We focus on this section later in the composition API section
*/
/* ... */
const color = computed(() => {
// ...
});
</script>
<style scoped lang="scss">
/* Import or define styles here
based on the defined lang attribute */
h1 {
font-size: 100px;
font-weight: 100;
text-align: center;
}
</style>
Find the React equivalent here.
Using the Composition API
Look at the code snippet above and notice the keyword setup
in the script
tag: this is the recommended way to use the Composition API, an umbrella term that combines the Reactivity API and lifecycle hooks.
The Composition API can be compared to the transition from class-based React components to React Hooks because it brings similar advantages, such as better code organization by feature and improved separation of concerns. With it, we can use variables defined inside the script
tag directly in the template
section, and use functions like defineProps
without importing them.
The latter is possible because <setup script>
uses compile-time macros so that explicit imports are no longer necessary.
In the following example, we use defineProps
without importing it and the props are automatically available inside of template
:
<!-- DeleteButton.vue -->
<template>
<button @click="onClick()">{{ label }}</button>
</template>
<script setup lang="ts">
defineProps({
label: {
type: String,
default: "❌",
},
onClick: {
type: Function,
default: () => {
// no op
},
},
});
</script>
Find the React example here.
A quick note on styling Vue 3 components
As we discussed at the top, this article will not discuss styling in detail. But just like with React, you can style components using either vanilla CSS class
and style
tags or a CSS-in-JS approach, such as CSS Modules.
In the previous section, we used scoped styles (to avoid side-effects) with the CSS preprocessor Sass to style the h1
tag. We also bound inline styles with :style
, which refers to the color
variable defined in the script
section.
See how we bind classes in Vue below:
<!-- ToggleButton.vue -->
<template>
<div
class="toggle-button"
:class="{ 'all-checked': allChecked, 'is-visible': isVisible }"
@click="onToggle"
>
<button>∨</button>
</div>
</template>
<script setup lang="ts"> /* ... */ </script>
<style scoped lang="scss">
.toggle-button {
/* ... */
&.is-visible > button {
display: block;
}
&.all-checked > button {
color: #e6e6e6;
}
}
</style>
Here’s the React example.
In the template
, we:
- Assign the attribute
class
to our classtoggle-button
, as you would do with regular HTML - Assign the class
all-checked
if the variableallChecked
is truthy - Assign the class
is-visible
if the variableisVisible
is truthy
However, if you calculate the class name dynamically, you should opt for :class
. :class
is the shorthand form of the v-bind
directive (v-bind:class
); we’ll discuss directives more comprehensively in the next section.
Conditional rendering in templates with Vue directives
Vue provides many built-in directives, but the most common for conditional rendering are:
-
v-if
-
v-else
-
v-else-if
-
v-show
The difference between v-if
and v-show
is that the latter will always stay in the DOM and is just visually hidden, pretty much like display:none
vs. visibility:hidden
in CSS.
The UI part of a Vue component goes into the template
section with the help of an HTML-based template syntax. Similar to React, you have to import a child component in an SFC and use it with a custom tag that can optionally receive props.
In the next example, label
, active
, and on-click
are assigned as props for FilterButton
:
<!-- FilterButtons.vue -->
<template>
<div class="filter-buttons">
<FilterButton
label="All"
:active="filterIndex === FilterIndex.ALL"
:on-click="handleAllClick"
/>
<!-- ... -->
</div>
</template>
<script setup lang="ts">
// import child component to use it in template section
import FilterButton from "./FilterButton.vue";
// ...
</script>
Here’s the React example.
Let me briefly clarify what you see in the snippet above. label
is a prop that receives a string value of All
. The assigned string values for the other props are interpreted as JavaScript expressions (remember, :
is short for v-bind
).
In contrast to conditional rendering in React with vanilla JavaScript concepts, like the ternary operator in Vue's template
section, you make use of built-in directives.
The following snippet shows how to use v-if
to render the component ClearButton
conditionally whenever the variable showClearButton
is truthy:
<template>
<!-- ActionBar.vue -->
<div class="action-bar" :style="theme">
<!-- ... -->
<ClearButton v-if="showClearButton" :on-click="handleClearClick" />
</div>
<div class="drop-shadow" />
</template>
Here’s the React example.
You can also use v-if
for an entire template
tag instead of a component, like so:
<template v-if="showIt">
Consequently, you can nest template
tags in the root template
tag to organize your UI code. We will see examples in the section about named slots.
Rendering lists of items with v-for
A common use case for conditional rendering is rendering a list of components: you add the v-for
directive to the component TodoItem
when you want to render a number of instances, based on a variable storing iterable data.
Take a look:
<template>
<!-- TodoList.vue -->
<div class="todo-list">
<TodoItem v-for="todo in filteredTodos" :key="todo.id" :todo="todo" />
</div>
</template>
Here’s the React example.
In this example, filteredTodos
is an array and todo
represents the current iterated item. As in React, you have to specify a key (:key
) to prevent bugs during re-rendering.
We then assign the id
prop of the current item to the key
prop of TodoItem
. Finally, we pass the whole iterated item as a prop to the underlying child component with :todo="todo"
.
Rendering JavaScript via text interpolation
If you want to render a variable or JavaScript expression via text interpolation in a Vue template
, you use double curly braces ("mustache syntax").
The mustache syntax does not work inside of attributes. This is where the v-bind
directive comes into play. In the below example, we use such a directive with the style
attribute (:style
, or the long form v-bind:style
) as we render the value of the prop label
:
<!-- FilterButton.vue -->
<template>
<button
:style="{
borderColor: active ? theme.color : 'transparent',
backgroundColor,
color,
}"
@click="onClick()"
>
{{ label }}
</button>
</template>
<script setup lang="ts">
defineProps({
label: String,
active: Boolean,
onClick: Function,
});
/* ... */
</script>
This is different from React, where you use only single curly braces (see { label }
in FilterButton.jsx
, our corresponding React example).
Multi-root components with Vue fragments and fallthrough attributes
In Vue 3, there is no limitation on single-root elements inside of the template
tag, thanks to Vue fragments, which is conceptually similar to React's fragment element (<></>
):
<template>
<!-- App.vue -->
<MailForm @subscribe-newsletter="onSubscribeNewsletter" />
<Headline font-color="red" text="todos" class="headline" />
<Todos />
</template>
Compare this to our React example.
Vue features a mechanism called fallthrough attributes, which passes attributes to child components even if they are not defined as props (e.g., class
, style
, type
, etc.). They are automatically assigned to the route element of the component.
Consider the following example, where I use the Headline
component inside of the App
component:
<!-- App.vue -->
<template>
<!-- ... -->
<Headline font-color="red" text="todos" class="headline" />
</template>
<!-- Headline.vue -->
<script lang="ts" setup>
/* ... */
const prop = defineProps({
fontColor: {
type: String as PropType<HeadlineColor>,
default: "red",
},
text: {
type: String,
required: true,
},
});
Even though the defined props in Headline.vue
are fontColor
and text
, the class
attribute is automatically available. If Headline
were a component with a single-root element, the class
attribute would be automatically applied to it.
However, I artificially created a multi-root component to demonstrate how to handle fallthrough attributes in this situation. Now, Vue needs assistance with where to assign these attributes:
<template>
<!-- Headline.vue -->
<div><!-- useless; just for demo purposes --></div>
<h1 :class="$attrs.class" :style="{ color }">{{ text }}</h1>
</template>
Here’s our React example.
The class
attribute, along with all other fallthrough attributes, is stored in the $attrs
variable (and defined in App.vue
), which is automatically available in the template
section.
Accessing DOM nodes with template refs
There are situations where you have to access DOM elements directly, e.g., to focus an input field after mounting a corresponding Vue component. It's pretty similar to React, where you use useRef
for this.
With the help of template refs, we focus the input field to enter to-dos just after the TodoInput.vue
component mounts:
<!-- TodoInput.vue -->
<template>
<div class="todo-input">
<!-- ... -->
<input
ref="inputRef"
v-model="inputValue"
:placeholder="placeholder"
@keyup.enter="onEnter"
/>
</div>
</template>
<script setup lang="ts">
/* ... */
import { ref, onMounted, type Ref } from "vue";
/* ... */
const inputRef: Ref<HTMLInputElement | null> = ref(null);
onMounted(() => {
if (inputRef.value != null) {
inputRef.value.focus();
}
});
/* ... */
This is what this looks like in React.
We create a ref
and assign it to a variable (inputRef
). In the template
section, we assign this variable to the template ref (ref="inputRef"
). Now, we can access the input field via inputRef
and call the focus()
method.
In order to avoid TypeScript errors in this example, we need to type the inputRef
with the help of Ref
and communicate that the variable can be either null
in the beginning or of type HTMInputElement
. In addition, we have to check in the onMounted
callback first, to see whether or not inputRef.value
has been assigned to the DOM element.
onMounted
is an example of a Vue lifecycle method, which we will discuss later.
Working with props in Vue 3
The concept behind Vue's prop mechanism is similar to React’s. Let's look at an example (FilterButtons.vue
) that passes props to a child component (FilterButton.vue
).
<!-- FilterButtons.vue -->
<template>
<FilterButton
label="All"
:active="filterIndex === FilterIndex.ALL"
:on-click="handleAllClick"
/>
</template>
See the React example in full.
Remember, a colon (:
) before a prop name indicates that the value is not a string, but should be interpreted as the abbreviation for the built-in directive v-bind
:
v-bind:active="filterIndex === FilterIndex.ALL"
In contrast, the label
prop only receives a hard-coded string value (ALL
), so the following variant works, but is unnecessary:
:label="'All'"
Let's look at the part where we define props. With defineProps
, you define the props along with its data types:
<script setup lang="ts">
// FilterButton.vue
defineProps({
label: String,
active: Boolean,
onClick: Function,
});
</script>
Here’s the React example.
Now, there are some interesting things to consider. First, defineProps
can be used without importing it because it’s automatically available inside <setup script>
, thanks to Vue's Composition API and compiler macros.
Second, with this single call, you can directly use these props within the template
section:
<template>
<button
:style="{
borderColor: active ? theme.color : 'transparent',
backgroundColor,
color,
}"
@click="onClick()"
>
{{ label }}
</button>
</template>
<script setup lang="ts">
defineProps({
label: String,
active: Boolean,
onClick: Function,
});
</script>
However, if you need to access props within the script
tag, you have to assign them to a variable:
<script setup lang="ts">
// TodoItem.vue
const props = defineProps({
todo: Object
});
const isCrossedOut = computed(() => props.todo.checked === true);
// ...
</script>
Take a look at how this is done in React.
Prop type validation with runtime declarations
You can pass an object to the defineProps
macro to declare the props that a component can receive. The data types in the above example (String
, Boolean
, and Function
) are conceptually similar to runtime type checking for React props.
In contrast to React, you don't have to import an additional library for this in Vue; Vue uses JavaScript’s built-in types, all of which are currently supported for runtime validation:
- String
- Number
- Boolean
- Array
- Object
- Date
- Function
- Symbol
As you can see in the following screenshot, you also get a prop warning in the browser’s JavaScript console if you don’t pass the correct data type to props when using Vue: If you used our previously recommended ESLint config, the linter should call for an improved variant to declare props:
defineProps({
label: {
type: String,
required: true,
},
active: {
type: Boolean,
default: false,
},
onClick: { type: Function, required: true },
});
Like in React, you can define optional props, for which you have to define default values. You can also mark props as required with required:true
.
You may ask, how do we work with non-primitive types like Object
or Array
? With the PropType API:
<script setup lang="ts">
// TodoItem.vue
import { type PropType } from "vue";
defineProps({
todo: {
type: Object as PropType<{
id: number;
label: string;
date: string;
checked: boolean;
}>,
required: true,
},
});
</script>
Here is a similar React example.
The example shows that the todo
prop has a JavaScript type of Object
. However, with PropType's generic argument, you can define the properties the object has. In the part between <>
, we provide a complex type defined with TypeScript.
Let's look at another way to use TypeScript for prop types in the next section.
Prop types with type-based annotations: Pure TypeScript approach
Besides runtime declarations, where you pass an argument to defineProps
at runtime, there is a more elegant variant with pure TypeScript annotations:
<script setup lang="ts">
// TodoItem.vue
defineProps<{
todo: {
id: number;
label: string;
date: string;
checked?: boolean;
}>();
</script>
Here’s the above, but in React.
You can also use custom types or interfaces:
<script setup lang="ts">
interface Props {
todo: {
id: number;
label: string;
date: string;
checked?: boolean;
};
}
defineProps<Props>();
</script>
Until version 3.3, Vue did not support importing types from another file — you had to stick to the PropType
utility. This is no longer a problem, though, and the following is now possible:
<script setup lang="ts">
// TodoItem.vue
import type { TodoItemProps } from "./types";
// ...
// this is not possible with vue < 3.3
const props = defineProps<TodoItemProps>();
</script>
Another caveat when relying on pure, type-based prop declarations is losing the ability to define prop default values. Instead, you have to use the compiler macro withDefaults
:
<script setup lang="ts">
// DeleteButton.vue
// alternative
/* defineProps({
label: {
type: String,
default: "❌",
},
onClick: {
type: Function,
default: () => {
// no op
},
},
}); */
type Props = {
label?: string;
onClick?: () => void;
};
withDefaults(defineProps<Props>(), {
label: "❌",
onClick: () => {
// no op
},
});
// ...
</script>
Here’s the React example.
Wondering when to use each variant? Or maybe you’re wondering why we don’t use both variants to have compile-time prop validation based on TypeScript, and runtime prop validation with the runtime declaration.
The thing is, you have to choose a single variant. Using both at the same time will result in a compiler error:
If you decide to use type-based annotations, the compiler infers the runtime options automatically.
Since runtime prop validation is also available with type-based annotations, this variant is recommended by large parts of the Vue community.
Destructuring props is an anti-pattern
In React, you've most likely destructured props on every component. But with Vue and the Composition API, you shouldn't do it.
Vue props provided by defineProps
are reactive objects and you will lose reactivity when you perform object destructuring. The rule of thumb is: Don't destructure props.
Despite this warning, it looks like this could soon be a thing of the past: with the release of Vue 3.3, destructuring reactive props is now an experimental feature.
In the <script setup>
you have to access the properties on the props
object directly:
<script setup>
const props = defineProps({
label: {
type: String,
required: true,
}
});
console.log(props.label);
</script>
If you cannot live without destructuring but are willing to take an extra step, you can use the Reactivity utility function toRefs
.
Vue slots: Rendering children or props
Vue provides a mechanism called slots, which allows you to nest React components inside of JSX so that content is passed in a children
prop.
Below is the React way, which you can see here:
// AppLayout.jsx
function AppLayout({ children }) {
return (
<LayoutContainer>
{ /* ... */ }
<Main>{children}</Main>
</LayoutContainer>
);
}
// App.jsx
const App = () => {
return (
{ /* ... */ }
<AppLayout>
<Todos />
</AppLayout>
);
};
In Vue, you use a slot outlet to render the passed template content. The above example can be implemented with Vue's default slot. It is a slot without a name; thus, we assign it the name default
implicitly (<slot
name="default">
):
<!-- App.vue -->
<template>
<AppLayout>
<!-- ... -->
<template #default>
<Todos />
</template>
</AppLayout>
</template>
<!-- AppLayout.vue -->
<template>
<div id="app-layout">
<!-- ... -->
<main>
<!-- or <slot name="default"></slot> -->
<slot>
🚀 <!-- fallback content -->
</slot>
</main>
</div>
</template>
In our case, the Vue component Todos
will render in the spot marked by <slot></slot>
, i.e., the default slot of the AppLayout
component.
In contrast to React’s children
prop, you have more options with Vue's slot mechanism, such as to provide fallback content whenever the parent component doesn’t provide slot content. In the above, we used the rocket emoji as a fallback. This is not limited to text content and can be a nested markup of HTML or other Vue components.
Let's see how we can make more use of slots in Vue components. You can only have one default slot — the others need to be named, i.e., newsletter
and headline
. We use these two named slots below:
<!-- App.vue -->
<template>
<AppLayout>
<template #newsletter>
<MailForm @subscribe-newsletter="onSubscribeNewsletter" />
</template>
<template #headline>
<Headline font-color="red" text="todos.vue" class="headline" />
</template>
<template #default>
<Todos />
</template>
</AppLayout>
</template>
<!-- AppLayout.vue -->
<template>
<div id="app-layout">
<aside>
<slot name="newsletter"></slot>
</aside>
<header>
<slot name="headline"></slot>
</header>
<main>
<slot></slot>
</main>
</div>
</template>
This is not possible with a children
prop in React. You could do the following, but this is not a common pattern from my point of view:
// AppLayout.jsx
function AppLayout({ headline, newsletter, children }) {
return (
<LayoutContainer>
<Aside>{newsletter}</Aside>
<Header>{headline}</Header>
<Main>{children}</Main>
</LayoutContainer>
);
}
// App.jsx
const App = () => {
// ...
return (
{ /* ... */ }
<AppLayout
headline={<Headline />}
newsletter={
<MailForm onSubscribeNewsletter={ /* ... */ }/>
}>
<Todos />
</AppLayout>
);
};
Here’s the above React example.
Slots also have downsides, including limitations on restricting the content they render. If you rely on props, you have more possibilities with the help of prop type validation and TypeScript. On the other hand, slots allow for much more flexibility. In the end, what you choose is mostly a matter of taste.
Simulating React's render props with scoped slots
Vue also provides scoped slots, which can be seen as Vue's equivalent to React render
props. These concepts look different and may differ in detail, but they are functionally similar.
Let's look at a Vue implementation that does the same job as the render prop used in TodoList.jsx
(renderItem
):
<!-- Todos.vue -->
<template>
<!-- ... -->
<TodoList>
<template #todo="{ todo }">
<TodoItem :key="todo.id" :todo="todo" />
</template>
</TodoList>
</template>
<!-- TodoList.vue -->
<template>
<div class="todo-list">
<slot name="todo" v-for="todo in filteredTodos" :todo="todo"></slot>
</div>
</template>
Here’s a link to the full React example.
In the Todos
component, the named slot can receive props, which can be seen as parameters of the render
prop. In TodoList
, we pass props to the slot
tag as usual (:todo="todo"
).
In our example, we directly destructured the todo
prop and used it in the template
body to render a TodoItem
component with the right data. The following is an alternative:
<!-- Todos.vue -->
<template>
<!-- ... -->
<TodoList>
<template #todo="slotProps">
<TodoItem :key="slotProps.todo.id" :todo="slotProps.todo" />
</template>
</TodoList>
</template>
Working with Vue's Reactivity API and two-way data binding
Similar to useState
, if you mutate reactive data, the corresponding Vue component updates its view. This is where the Reactivity API comes into play.
It is an umbrella term in Vue that combines various concepts and APIs within the topic of reactivity. There are two major concepts behind creating component state as you can with useState
in React:
- Reactive state objects with
reactive
- Reactive variables with
ref
You can choose between these approaches based on your use case and taste, as both techniques have advantages and disadvantages. Let's start with reactive
.
Working with reactive state objects using reactive
If you are a long-time React developer, surely you remember how to use state in class-based components: you assign nested objects to setState
. The situation with reactive
is comparable because it can only hold objects and not primitive values.
Let's look at the MailForm
component that uses a reactive state object:
<!-- MailForm.vue -->
<template>
<!-- ... -->
<input :value="user.firstName" @input="handleFirstNameChange" />
<!-- ... -->
</template>
<script setup lang="ts">
import { reactive } from "vue";
type User = {
firstName: string;
mail: string;
frequency: "weekly" | "monthly";
};
const user = reactive<User>({ firstName: "", mail: "", frequency: "weekly" });
const handleFirstNameChange = (evt: Event) => {
user.firstName = (evt.target as HTMLInputElement).value;
};
// ...
</script>
Let's look at the reactive part now; we’ll discuss the different aspects of two-way data binding step by step in a moment. You can take a look at the React example here.
The following statement creates the reactive state object user
. Under the hood, Vue uses a JavaScript Proxy
object. I decided to use reactive
instead of individual ref
s because these variables are meant to be together and constitute a state as a union:
const user = reactive<User>({ firstName: "", mail: "", frequency: "weekly" })
We use TypeScript and the custom type User
as generic parameters of reactive
to guarantee type safety. We then initialize the reactive object with default values for the three properties.
With a simple mutation, we can update the state object, as you can see here:
user.firstName = // assign a value
When we use the properties of this reactive object in the template, the view updates after the mutation (<input :value="user.firstName"
).
If you inspect the MailForm
component in Vue DevTools, you will find the reactive user
object.
Working with reactive variables using ref
A ref
is a reactive and mutable object with a single property, value
, to hold its data, which can be primitive data or object types. Any read operation of value
is tracked and mutation triggers associated effects.
Conceptually, ref
cannot be compared to the useRef
Hook, but their handling is similar because you also access data via the current
property. Instead, ref
has more conceptual similarity to useState
because you create a state variable with either.
As with useState
, a best practice here is to create individual ref
s instead of nested objects. Let's look at an example:
<!-- TodoItem.vue -->
<template>
<!-- ... -->
<DeleteButton v-show="hover" :on-click="handleDeleteClick" />
<!-- ... -->
</template>
<script setup lang="ts">
import { ref } from "vue";
// ...
const hover = ref(false);
const onMouseOver = () => {
return (hover.value = true);
};
const onMouseOut = () => {
return (hover.value = false);
};
// ...
</script>
Here’s the React example.
We created a reactive variable hover
with an initial falsy value. It gets mutated in the mouse event handlers by assigning new Boolean values to the value
property. You get an error at development time when you try to pass something other than a Boolean because the ref
function infers the type.
For your convenience, Vue automatically unwraps the value when you access the ref
in the template
section (v-show="hover"
). If an object is assigned as a ref
's value, the object is made deeply reactive with reactive
.
Controlling form input fields
A common use case for frontend apps is to build a form. Let's apply our acquired knowledge about reactive data to create two-way data binding similar to React’s controlled components.
If you want to refresh your React knowledge about controlled components with useState
, I suggest this LogRocket article comparing useState
vs. useRef
.
The first form field shows how to implement two-way binding with Vue. The input value is bound to a reactive object (user.firstName
) as described in the last section:
<!-- MailForm.vue -->
<template>
<!-- ... -->
<fieldset>
<!-- ... -->
<input :value="user.firstName" @input="handleFirstNameChange" />
</fieldset>
<!-- ... -->
</template>
<script setup lang="ts">
import { reactive } from "vue";
type User = {
firstName: string;
mail: string;
frequency: "weekly" | "monthly";
};
const user = reactive<User>({ firstName: "", mail: "", frequency: "weekly" });
const handleFirstNameChange = (evt: Event) => {
user.firstName = (evt.target as HTMLInputElement).value;
};
// ...
</script>
Here’s the React example.
In this snippet, we use value binding and event binding. We combine v-bind
(v-bind:value
, or the shorter :value
) with v-on
(v-on:input
, or the shorter @input
) to react to input changes and write them back to the state (user.firstName
). We react to input change events and call the callback handleFirstNameChange
.
Improving two-way data binding with v-model
A more elegant variant is to use v-model
, which encapsulates both value binding and event binding. We can see how this works with the second input field:
<!-- MailForm.vue -->
<template>
<!-- ... -->
<fieldset>
<!-- ... -->
<input v-model="user.mail" />
</fieldset>
<!-- ... -->
</template>
Here is the React example.
We’ve done the same job but with less code. We don't need an explicit event handler to reflect the v-model
value to the state variable (user.mail
). If you’re interested, you can read in great detail about this topic in another LogRocket article.
Form submission
Just as with React, we need to prevent the default HTML form behavior by calling event.preventDefault()
and using our own callback function instead. Sure, you can call this from inside of your event handler method, but it's more concise to use the event modifier prevent
of the submit event (@submit.prevent
):
<template>
<!-- MailForm.vue -->
<form @submit.prevent="handleSubmit">
<!-- ... -->
</form>
</template>
<script setup lang="ts">
// ...
const emit = defineEmits(["subscribe-newsletter"]);
const handleSubmit = () => {
emit("subscribe-newsletter", user.firstName);
};
// ...
</script>
Here’s the React example.
In our example, handleSubmit
calls a function created by the macro defineEmits
. In the next section, we’ll look at how to use it for event handling.
Listening to custom events in parent components
In the previous section, handleSubmit
emits a custom event called subscribe-newsletter
. As a second argument, we can pass an event payload to emit
. We can listen to this event in a parent component:
<!-- App.vue -->
<template>
<MailForm @subscribe-newsletter="onSubscribeNewsletter" />
<!-- ... -->
</template>
<script setup lang="ts">
// ...
const onSubscribeNewsletter = (firstName: string) => {
console.log(`Subscribe newsletter for ${firstName}`);
};
</script>
Here’s the same example in React.
In App.vue
, we receive the event with the event binding @subscribe-newsletter
. We assign a callback function (onSubscribeNewsletter
), which receives our string
value as an argument (firstName
).
A common way to solve this with React is to pass a callback function as prop to the child component. In our Vue App.jsx
, we pass a function prop named onSubscribeNewsletter
that accepts a parameter firstName
to the MailForm
component. We then invoke this callback function inside of the child component in the form submission handler.
With Vue’s DevTools, you can inspect fired events in the Timeline
section:
Using Vue's composables as React Hook counterparts
A Vue composable is similar to a React custom Hook. It encapsulates non-UI Vue code into a sole function. Composables leverage the Composition API to reuse stateful Vue logic in components. An ordinary function becomes a composable when it makes use of the aforementioned API methods part of the vue
package, such as ref
, onMounted
, etc.
Let's look at a simple example composable, useTitleSimple
, which updates the document title. The naming convention is clearly inspired by React, so your composables also start with use<SomeSemanticName>
.
The goal of our example is to adjust the title automatically using a watcher. Whenever we update the composable’s associated reactive variable, the title adjusts accordingly:
// composables/useTitle.ts
import { type Ref, watchEffect } from "vue";
/* ... */
export const useTitleSimple = (title: Ref<string>) => {
watchEffect(() => {
document.title = title.value;
});
};
Here is what this looks like in React.
The composable accepts a ref
named title
of type string
, which becomes Ref<string>
. We assign the value of this ref
(title.value
) to watchEffect
to update the document's title.
Whenever the reactive title
is updated outside of the composable, it triggers the watcher and is re-run.
The following listing shows how to use the above composable inside the TodoInput
component. We assign the title of the last created to-do to the document's title:
<!-- TodoInput.vue -->
<template>
<!-- ... -->
<input
<!-- omit other props -->
v-model="inputValue"
@keyup.enter="onEnter"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useTitleSimple } from "@/composables/useTitle";
/* ... */
const inputValue = ref("");
// save the last created todo in a reactive variable
// init the reactive variable with the current document title
const recentTodo = ref<string>(document.title);
// use the last todo as document title
useTitleSimple(recentTodo);
/* ... */
const onEnter = (evt: Event) => {
const { value } = <HTMLInputElement>evt.target;
if (value.trim() !== "") {
/*
create a new todo,
not relevant for this example
*/
/* mutation of reactive variable triggers
the watcher inside of the composable */
recentTodo.value = inputValue.value;
/* clear the input field */
inputValue.value = "";
}
};
</script>
Here’s the React example.
Let's break down the important parts of this code snippet. In the template
section, we use v-model="inputValue"
to bind a ref
named inputValue
to an input field. Additionally, we define a keyboard event handler: @keyup.enter="onEnter"
.
Whenever the user types into the input field and presses Enter, the callback onEnter
is invoked. Inside this function, we add a new to-do to the input control value.
This part isn’t relevant to our example so I skipped it, but assigning the inputValue
to the reactive variable recentTodo
is crucial because we use recentTodo
as the input of the composable:
>useTitleSimple(recentTodo)
If you are familiar with React's custom Hooks, composables should look very similar. You can find many examples of reusable composables in the VueUse library, which is a good learning resource for best practices regarding composable inputs and return values, and provides the documentation and source code for composables for your study.
Speaking of good practices, let's discuss a couple of them in the next section.
Composables with flexible inputs and dynamic outputs
Below is a more advanced example of the previous composable. It's also part of useTitle.ts
and can be used by a default import:
// composables/useTitle.ts
import { type MaybeRef, watchEffect, ref } from "vue";
type Options = {
observe?: boolean;
prefix?: string;
};
export default (title: MaybeRef<string>, options?: Options) => {
const titlePrefix = options?.prefix ?? "";
const observe = options?.observe ?? true;
const titleRef = ref(title);
if (observe) {
watchEffect(() => {
document.title = titlePrefix + titleRef.value;
});
} else {
document.title = titlePrefix + titleRef.value;
}
return {
setTitle: (newTitle: string) => {
document.title = titlePrefix + newTitle;
},
titleRef,
};
};
Here is how we’d accomplish this in React.
This example is also useful for demonstrating the Reactive API’s utility functions. First of all, we make the composable more flexible by allowing a raw string
value or a ref
of type string
by typing the argument as MaybeRef<string>
.
We can entirely omit the second argument of the composable, options
, because of ?
. If you omit it completely, both default values get assigned in line 8 and 9. If an argument is present, the observe
and prefix
properties can also be omitted.
Therefore, we can provide default values if options are not provided. The titlePrefix
variable defaults to an empty string
and the observe
flag is set to true
.
With the first argument, title
, we create a ref
named titleRef
. If title
is already a ref
, this value is returned; otherwise a new ref
is created.
If the observe
config flag is set to true
, we set up a watchEffect
to adapt the document title whenever titleRef
's value gets changed. However, because you can have observe
set to true
but also provide a raw string
, we return the titleRef
as a composable output.
If observe
is falsy, we don't set up a watcher and instead assign the initial value in the else
condition. Then, the consumer of the composable has to use a callback function, setTitle
, to adapt the document title.
Because of this, we can use the composable in TodoInput
in multiple ways:
<!-- variant 1 -->
<script setup lang="ts">
/* ... */
const recentTodo = ref<string>(document.title);
/* title: ref<string>, observe: true, prefix: 🚀 */
useTitle(recentTodo, { prefix: "🚀 " });
/* ... */
const onEnter = (evt: Event) => {
/* ... */
// the watcher gets triggered
recentTodo.value = inputValue.value;
});
</script>
<!-- variant 2 -->
<script setup lang="ts">
/* ... */
/* title: raw string, observe: true, prefix: "" */
const { titleRef } = useTitle(document.title);
// use returned `ref` from the composable
const recentTodo: Ref<string> = titleRef;
/* ... */
const onEnter = (evt: Event) => {
/* ... */
// the watcher gets triggered
recentTodo.value = inputValue.value;
});
</script>
<!-- variant 3 -->
<script setup lang="ts">
/* ... */
/* title: raw string, observe: false, prefix: "" */
const { setTitle } = useTitle(document.title, { observe: false });
/* ... */
const onEnter = (evt: Event) => {
/* ... */
// we need to use the returned setter to update
setTitle(inputValue.value);
});
</script>
These example composables show that it’s a good approach to list your composable options as optional objects after passing the required arguments. You should also provide default values in such a way that your composable can handle any constellation of inputs.
Similarities and differences between React Hooks and Vue composables
If you have experience with React Hooks, you will already find most of the underlying principles of composables familiar. In contrast to React, there are no Rules of Hooks that force you to use composables at the top level of a Vue component. You can use them in conditional code, but this is most likely not a good idea. Of course, you can also use composables inside of other composables. They can even be used outside of components!
With React Hooks, you rely on useState
for managing state, and with composables you make use of the Reactivity API.
watch
and watchEffect
are Vue's equivalent to React's useEffect
, which allow you to re-evaluate composables and invoke side effects. Composables can also leverage lifecycle Hooks, such as onMounted
. We’ll discuss watchers
and lifecycle Hooks in more depth in the next section.
Rendering and component lifecycles with Vue composables
Though we’ve been able to draw some similarities between React and Vue so far, their conceptual foundations actually differ drastically, which will become particularly evident in this section.
If you are familiar with the lifecycle methods that are part of class-based React components, then the learning curve to understanding Vue's lifecycle hooks won’t be steep.
Hooking into lifecycle stages and reacting to state changes
In our previous section example, we used the onMounted
Hook to focus the input field to create to-dos after the initial render of TodoInput.vue
:
<script setup lang="ts">
// TodoInput.vue
import { onMounted } from "vue";
// ...
onMounted(() => {
// ...
});
// ...
</script>
Here’s the React example.
The conceptual counterpart in functional React that most closely mirrors this is to use useEffect
with an empty dependency array:
// TodoInput.jsx
useEffect(() => {
// ...
}, []);
There exist other lifecycle hooks. For example, unmounting in Vue could look like this:
<script setup lang="ts">
import { onUnmounted } from "vue";
onUnmounted(() => {
// do cleanup
});
</script>
// TodoInput.jsx
useEffect(() => {
// ...
return () => {
// do cleanup
}
}, []);
With onUpdated
, you can invoke a callback after Vue updates the DOM tree; there is no meaningful React counterpart to this.
Watching state changes and performing side effects
Conceptually, there are similar use cases for the above when watching state changes and performing side effects with both React and Vue, but they also differ greatly. We already saw one example with the useTitle
Hook or composable, respectively:
// useTitle.ts (Vue)
watchEffect(() => {
/* ... */ = title.value;
});
// useTitle.js (React)
useEffect(() => {
// ...
}, [title]);
In Vue, we use watchers like watchEffect
to analyze the body of the callback function and is invoked for every reactive variable (title
).
With React, we have to add a state variable (title
) to useEffect
's dependency array to be invoked on every state update.
If we need to get the previous value of a Vue state, we can also use watch
:
// useTitle.ts
export default (title: MaybeRef<string>, options?: Options) => {
// ...
const titleRef = ref(title);
if (observe) {
// ...
watch(titleRef, (newTitle, oldTitle) => {
console.log(`watch new: ${newTitle} old: ${oldTitle}`);
});
}
// ...
}
The difference between watch
and watchEffect
is that we explicitly tell Vue which reactive variables to watch. In this case, we watch titleRef
, a ref
of type string
. The second argument is a callback that gets the raw values of the new and old states.
Vue lifecycle hooks can be used in separate functions outside of the <script setup>
block, such as inside composables. However, you must invoke the callback function synchronously — if you don't use a <script setup>
component approach, make sure that the call stack originates from within the setup phase (i.e., from setup()
).
Component rendering
Let's use the onUpdated
hook to find out whenever Vue updates the DOM of a component. In addition, I'm interested to see how often the "setup phase", i.e., the <script setup>
section, is traversed by Vue.
As we know, React runs the function, which constitutes the component, on every state update. I added a couple of console.log
s to the components like the following example (Todos.jsx
):
// Todos.jsx
const Todos = () => {
console.log("render <Todos />");
// ...
}
<!-- Todos.vue -->
<script setup lang="ts">
import { onUpdated } from "vue";
console.log("render <Todos />");
onUpdated(() => {
console.log("updated <Todos />");
});
// ...
</script>
This is the result of the Vue app after loading: And this is how it looks in React:
So far, these apps look the same. The interesting part is how the two technologies differ throughout user interactions.
I loaded both apps and cleaned the console output. Let's look at the Vue app first: This recording reveals that Vue runs the setu
p
phase only once — so components are rendered only once. No re-renders occur in this example, so only new components were rendered when I added a new to-do item. While hovering over the to-do items, a lot of update events were fired to adjust the DOM trees.
What about in React? Let’s see: After pressing Enter to create a to-do, a couple of components re-rendered. The same happened when I hovered over the to-do items.
A sophisticated performance analysis of both technologies is out of scope here, but I believe that React’s component re-renders take a larger hit on performance because DOM updates take place behind the scenes. Under normal circumstances, Vue does not re-render components after the initial rendering cycle.
For small and mid-size React projects, this is not much of a problem. You can opt out of these re-renders in React with Hooks like useMemo
or useCallback
, or by choosing wisely between useState
or useRef
. All together, though, this adds some more complexity to projects and might raise the entry barrier for developers who are new to React.
Arek Nawo describes this difference as the "in vs. out" principle, in the sense that React devs have to opt out of re-renders (e.g., with useMemo
or dependencies in Hooks like useEffect
) and with Vue, devs need to opt in to redraws (e.g., with watchers).
Conclusion
React and Vue are two different animals, but they are both built to realize the same frontend development use cases. Though the fundamental design concepts behind React and Vue differ greatly, I believe they each enable a quick entry into a new framework, especially when you compare one technology with the other conceptually, from a higher abstraction level.
Experience your Vue apps exactly how a user does
Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If you’re interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens in your Vue apps including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.
The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.
Modernize how you debug your Vue apps - Start monitoring for free.
Top comments (0)