Vue 3 Composition API入門:リアクティブな開発を始めよう

Vue 3のComposition APIの基本から実践的な使い方まで、Options APIとの違いを含めて詳しく解説します。

Vue 3 Composition API 入門

Vue 3 で導入された Composition API は、より柔軟で再利用可能なコードを書くための新しいアプローチです。従来の Options API との違いを理解し、実践的な使い方を学びましょう。

Composition API と Options API の比較

Options API(従来の書き方)

<template>
  <div>
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p>投稿数: {{ posts.length }}</p>
    <button @click="fetchPosts">投稿を取得</button>
  </div>
</template>

<script>
export default {
  name: "UserProfile",

  data() {
    return {
      user: {
        name: "",
        email: "",
      },
      posts: [],
      loading: false,
    };
  },

  computed: {
    userDisplayName() {
      return `${this.user.name} (${this.user.email})`;
    },
  },

  methods: {
    async fetchUser(userId) {
      this.loading = true;
      try {
        const response = await fetch(`/api/users/${userId}`);
        this.user = await response.json();
      } catch (error) {
        console.error(error);
      } finally {
        this.loading = false;
      }
    },

    async fetchPosts() {
      const response = await fetch(`/api/users/${this.user.id}/posts`);
      this.posts = await response.json();
    },
  },

  mounted() {
    this.fetchUser(1);
  },
};
</script>

Composition API(新しい書き方)

<template>
  <div>
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <p>投稿数: {{ posts.length }}</p>
    <button @click="fetchPosts">投稿を取得</button>
  </div>
</template>

<script>
import { ref, reactive, computed, onMounted } from "vue";

export default {
  name: "UserProfile",

  setup() {
    // リアクティブな状態
    const user = reactive({
      name: "",
      email: "",
    });

    const posts = ref([]);
    const loading = ref(false);

    // 算出プロパティ
    const userDisplayName = computed(() => {
      return `${user.name} (${user.email})`;
    });

    // メソッド
    const fetchUser = async (userId) => {
      loading.value = true;
      try {
        const response = await fetch(`/api/users/${userId}`);
        const userData = await response.json();
        Object.assign(user, userData);
      } catch (error) {
        console.error(error);
      } finally {
        loading.value = false;
      }
    };

    const fetchPosts = async () => {
      const response = await fetch(`/api/users/${user.id}/posts`);
      posts.value = await response.json();
    };

    // ライフサイクル
    onMounted(() => {
      fetchUser(1);
    });

    // テンプレートで使用する値や関数を返す
    return {
      user,
      posts,
      loading,
      userDisplayName,
      fetchPosts,
    };
  },
};
</script>

基本的なリアクティブ関数

ref と reactive

<script>
import { ref, reactive, isRef, toRefs } from "vue";

export default {
  setup() {
    // ref: プリミティブ値用
    const count = ref(0);
    const message = ref("Hello Vue 3!");

    // reactive: オブジェクト用
    const user = reactive({
      name: "John",
      age: 25,
      hobbies: ["reading", "coding"],
    });

    // ref値へのアクセス
    console.log(count.value); // 0
    count.value = 10;

    // reactive値へのアクセス
    console.log(user.name); // 'John'
    user.age = 26;

    // 分割代入でリアクティブ性を保持
    const { name, age } = toRefs(user);

    // ref かどうかの判定
    console.log(isRef(count)); // true
    console.log(isRef(user)); // false

    return {
      count,
      message,
      user,
      name,
      age,
    };
  },
};
</script>

computed

<script>
import { ref, computed } from "vue";

export default {
  setup() {
    const firstName = ref("太郎");
    const lastName = ref("山田");

    // 読み取り専用の算出プロパティ
    const fullName = computed(() => {
      return `${lastName.value} ${firstName.value}`;
    });

    // 書き込み可能な算出プロパティ
    const fullNameWritable = computed({
      get() {
        return `${lastName.value} ${firstName.value}`;
      },
      set(newValue) {
        const names = newValue.split(" ");
        lastName.value = names[0];
        firstName.value = names[1];
      },
    });

    return {
      firstName,
      lastName,
      fullName,
      fullNameWritable,
    };
  },
};
</script>

watch と watchEffect

<script>
import { ref, watch, watchEffect } from "vue";

export default {
  setup() {
    const count = ref(0);
    const user = reactive({
      name: "John",
      age: 25,
    });

    // 単一の値を監視
    watch(count, (newValue, oldValue) => {
      console.log(`カウントが ${oldValue} から ${newValue} に変更されました`);
    });

    // 複数の値を監視
    watch(
      [count, () => user.name],
      ([newCount, newName], [oldCount, oldName]) => {
        console.log(`カウント: ${oldCount} → ${newCount}`);
        console.log(`名前: ${oldName} → ${newName}`);
      }
    );

    // オブジェクトの深い監視
    watch(
      user,
      (newUser, oldUser) => {
        console.log("ユーザー情報が変更されました", newUser);
      },
      { deep: true }
    );

    // 即座に実行される監視
    watch(
      count,
      (newValue) => {
        console.log("初期実行:", newValue);
      },
      { immediate: true }
    );

    // watchEffect: 依存関係を自動追跡
    watchEffect(() => {
      console.log(`現在のカウント: ${count.value}`);
      console.log(`ユーザー名: ${user.name}`);
    });

    // 監視の停止
    const stopWatcher = watchEffect(() => {
      console.log(count.value);
    });

    // 条件によって監視を停止
    if (count.value > 10) {
      stopWatcher();
    }

    return {
      count,
      user,
    };
  },
};
</script>

ライフサイクル

<script>
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
  onErrorCaptured,
} from "vue";

export default {
  setup() {
    console.log("setup実行");

    onBeforeMount(() => {
      console.log("コンポーネントがマウントされる前");
    });

    onMounted(() => {
      console.log("コンポーネントがマウントされた後");
      // DOM操作やAPIコールなど
    });

    onBeforeUpdate(() => {
      console.log("コンポーネントが更新される前");
    });

    onUpdated(() => {
      console.log("コンポーネントが更新された後");
    });

    onBeforeUnmount(() => {
      console.log("コンポーネントがアンマウントされる前");
      // クリーンアップ処理
    });

    onUnmounted(() => {
      console.log("コンポーネントがアンマウントされた後");
    });

    onErrorCaptured((error, instance, info) => {
      console.log("エラーがキャプチャされました:", error);
      return false; // エラーの伝播を停止
    });

    return {};
  },
};
</script>

カスタム Composable 関数

useCounter(再利用可能なロジック)

// composables/useCounter.js
import { ref, computed } from "vue";

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = () => {
    count.value = initialValue;
  };

  const isEven = computed(() => count.value % 2 === 0);
  const isPositive = computed(() => count.value > 0);

  return {
    count,
    increment,
    decrement,
    reset,
    isEven,
    isPositive,
  };
}

useFetch(API 呼び出し)

// composables/useFetch.js
import { ref, readonly } from "vue";

export function useFetch(url) {
  const data = ref(null);
  const loading = ref(false);
  const error = ref(null);

  const execute = async () => {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }
      data.value = await response.json();
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  };

  // 初回実行
  execute();

  return {
    data: readonly(data),
    loading: readonly(loading),
    error: readonly(error),
    refetch: execute,
  };
}

useLocalStorage(ローカルストレージ)

// composables/useLocalStorage.js
import { ref, watch } from "vue";

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key);
  const initialValue = storedValue ? JSON.parse(storedValue) : defaultValue;

  const value = ref(initialValue);

  // 値の変更を監視してローカルストレージに保存
  watch(
    value,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue));
    },
    { deep: true }
  );

  return value;
}

実践例:Todo アプリ

<template>
  <div class="todo-app">
    <h1>Todo アプリ</h1>

    <form @submit.prevent="addTodo">
      <input v-model="newTodoText" placeholder="新しいタスクを入力" required />
      <button type="submit">追加</button>
    </form>

    <div class="filters">
      <button
        v-for="filter in filters"
        :key="filter.value"
        :class="{ active: currentFilter === filter.value }"
        @click="currentFilter = filter.value"
      >
        {{ filter.label }}
      </button>
    </div>

    <ul class="todo-list">
      <li
        v-for="todo in filteredTodos"
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        <input type="checkbox" v-model="todo.completed" />
        <span>{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)">削除</button>
      </li>
    </ul>

    <p>残りタスク: {{ remainingCount }} / 全{{ todos.length }}</p>
  </div>
</template>

<script>
import { ref, computed } from "vue";
import { useLocalStorage } from "@/composables/useLocalStorage";

export default {
  name: "TodoApp",

  setup() {
    // 状態管理
    const todos = useLocalStorage("todos", []);
    const newTodoText = ref("");
    const currentFilter = ref("all");

    const filters = [
      { value: "all", label: "すべて" },
      { value: "active", label: "未完了" },
      { value: "completed", label: "完了" },
    ];

    // 算出プロパティ
    const filteredTodos = computed(() => {
      switch (currentFilter.value) {
        case "active":
          return todos.value.filter((todo) => !todo.completed);
        case "completed":
          return todos.value.filter((todo) => todo.completed);
        default:
          return todos.value;
      }
    });

    const remainingCount = computed(() => {
      return todos.value.filter((todo) => !todo.completed).length;
    });

    // メソッド
    const addTodo = () => {
      if (newTodoText.value.trim()) {
        todos.value.push({
          id: Date.now(),
          text: newTodoText.value.trim(),
          completed: false,
          createdAt: new Date(),
        });
        newTodoText.value = "";
      }
    };

    const removeTodo = (id) => {
      const index = todos.value.findIndex((todo) => todo.id === id);
      if (index > -1) {
        todos.value.splice(index, 1);
      }
    };

    const toggleAll = () => {
      const allCompleted = todos.value.every((todo) => todo.completed);
      todos.value.forEach((todo) => {
        todo.completed = !allCompleted;
      });
    };

    const clearCompleted = () => {
      todos.value = todos.value.filter((todo) => !todo.completed);
    };

    return {
      todos,
      newTodoText,
      currentFilter,
      filters,
      filteredTodos,
      remainingCount,
      addTodo,
      removeTodo,
      toggleAll,
      clearCompleted,
    };
  },
};
</script>

<style scoped>
.todo-app {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
}

.todo-list {
  list-style: none;
  padding: 0;
}

.todo-list li {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.5rem;
  border-bottom: 1px solid #eee;
}

.todo-list li.completed span {
  text-decoration: line-through;
  opacity: 0.6;
}

.filters button {
  margin-right: 0.5rem;
  padding: 0.5rem 1rem;
  border: 1px solid #ddd;
  background: white;
  cursor: pointer;
}

.filters button.active {
  background: #007bff;
  color: white;
}
</style>

TypeScript との統合

<script lang="ts">
import { defineComponent, ref, computed, PropType } from "vue";

interface User {
  id: number;
  name: string;
  email: string;
}

interface Todo {
  id: number;
  text: string;
  completed: boolean;
  userId: number;
}

export default defineComponent({
  name: "UserTodos",

  props: {
    user: {
      type: Object as PropType<User>,
      required: true,
    },
  },

  emits: {
    "todo-added": (todo: Todo) => true,
    "todo-updated": (todo: Todo) => true,
  },

  setup(props, { emit }) {
    const todos = ref<Todo[]>([]);
    const newTodoText = ref<string>("");

    const completedCount = computed<number>(() => {
      return todos.value.filter((todo) => todo.completed).length;
    });

    const addTodo = (): void => {
      if (newTodoText.value.trim()) {
        const todo: Todo = {
          id: Date.now(),
          text: newTodoText.value.trim(),
          completed: false,
          userId: props.user.id,
        };

        todos.value.push(todo);
        emit("todo-added", todo);
        newTodoText.value = "";
      }
    };

    const updateTodo = (todo: Todo): void => {
      emit("todo-updated", todo);
    };

    return {
      todos,
      newTodoText,
      completedCount,
      addTodo,
      updateTodo,
    };
  },
});
</script>

パフォーマンス最適化

shallowRef と shallowReactive

import { shallowRef, shallowReactive, triggerRef } from "vue";

export default {
  setup() {
    // 大きなオブジェクトの場合、深いリアクティブ化を避ける
    const largeData = shallowRef({
      items: new Array(10000)
        .fill(0)
        .map((_, i) => ({ id: i, data: `item-${i}` })),
    });

    // 手動でリアクティブ更新をトリガー
    const updateLargeData = () => {
      largeData.value.items.push({ id: Date.now(), data: "new-item" });
      triggerRef(largeData); // 手動更新
    };

    // 浅いリアクティブオブジェクト
    const shallowState = shallowReactive({
      nested: {
        value: 1, // これは非リアクティブ
      },
    });

    return {
      largeData,
      updateLargeData,
      shallowState,
    };
  },
};

まとめ

Composition API は、Vue 3 の大きな特徴の一つで、より柔軟で再利用可能なコードを書くことができます。従来の Options API と併用することも可能なので、段階的に移行することができます。

カスタム Composable 関数を活用することで、ロジックの再利用性が高まり、コンポーネントがより保守しやすくなります。TypeScript との組み合わせにより、型安全性も確保できます。

最後まで読んでいただきありがとうございました!てばさん(@basabasa8770)でした!

この記事をシェア