DEV Community

Cover image for Vue 3 for React developers: Side-by-side comparison with demos
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

Vue 3 for React developers: Side-by-side comparison with demos

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

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/
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>&or;</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>
Enter fullscreen mode Exit fullscreen mode

Here’s the React example.

In the template, we:

  • Assign the attribute class to our class toggle-button, as you would do with regular HTML
  • Assign the class all-checked if the variable allChecked is truthy
  • Assign the class is-visible if the variable isVisible 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
  }
});
/* ... */
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

In contrast, the label prop only receives a hard-coded string value (ALL), so the following variant works, but is unnecessary:

:label="'All'"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: Prop type warning in the browser console 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 },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: Both variants result in errors

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Reactive state objects with reactive
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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 refs because these variables are meant to be together and constitute a state as a union:

const user = reactive<User>({ firstName: "", mail: "", frequency: "weekly" })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. Inspecting reactive data in Vue DevTools

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 refs 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: Vue DevTools allows for inspecting events

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;
  });
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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(() => {
    // ...
  }, []);
Enter fullscreen mode Exit fullscreen mode

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
    }
  }, []);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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}`);
    });
  } 
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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.logs 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>
Enter fullscreen mode Exit fullscreen mode

This is the result of the Vue app after loading: The initial renders of our Vue app And this is how it looks in React: The initial renders of our React app

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: The renders and DOM updates recorded in our Vue app This recording reveals that Vue runs the setup 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: The renders and DOM updates recorded in our React app 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 Signup

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)