採点されません。匿名です。 間違えることは学習の一部です。読むだけでもかまいません。

Vue — Composables

テクノロジ系 / L05 state-management — 状態管理

Vue — Composables

Composables = React の custom hook

// composables/useCounter.ts
import { ref } from "vue";

export function useCounter(initial = 0) {
  const count = ref(initial);
  const increment = () => count.value++;
  const decrement = () => count.value--;
  const reset = () => count.value = initial;
  return { count, increment, decrement, reset };
}

// 使う
<script setup>
import { useCounter } from "@/composables/useCounter.js";

const { count, increment } = useCounter(0);
</script>

「use」で始まる関数で 状態とロジックを共有

命名規則

  • 必ず use で始まる (慣習)
  • camelCase (useCounter, useLocalStorage)
  • 副作用の内容を名前で表す

useLocalStorage

import { ref, watch } from "vue";

export function useLocalStorage<T>(key: string, initial: T) {
  const raw = localStorage.getItem(key);
  const value = ref<T>(raw ? JSON.parse(raw) : initial);

  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal));
  }, { deep: true });

  return value;
}

// 使う
const theme = useLocalStorage("theme", "light");

useFetch

import { ref, watchEffect } from "vue";

type AsyncState<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: string };

export function useFetch<T>(urlGetter: () => string) {
  const state = ref<AsyncState<T>>({ status: "loading" });

  watchEffect(async (onCleanup) => {
    const ctrl = new AbortController();
    onCleanup(() => ctrl.abort());

    state.value = { status: "loading" };
    try {
      const res = await fetch(urlGetter(), { signal: ctrl.signal });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      state.value = { status: "success", data: await res.json() };
    } catch (err) {
      if ((err as Error).name === "AbortError") return;
      state.value = { status: "error", error: (err as Error).message };
    }
  });

  return state;
}

VueUse — 標準デファクト

$ pnpm add @vueuse/core
<script setup>
import { useLocalStorage, useMouse, useDebounceFn, useIntersectionObserver } from "@vueuse/core";

const theme = useLocalStorage("theme", "light");
const { x, y } = useMouse();
const debouncedSearch = useDebounceFn(search, 300);
</script>

200+ の useful composable が揃っている。

自前で書く前にまず VueUse を見るべし。

Composables vs Pinia

ComposablesPinia
state スコープ呼び出した componentグローバル共有
用途ロジック共有アプリ全体 state
使い分けローカル化グローバル化

両方使う。

  • 個別 component で完結 → Composable
  • 複数 component で共有 → Pinia

まとめ

  • use* 関数でロジックを共有
  • React の custom hook と同じ思想
  • VueUse に 欲しいものはほぼある
  • ローカルは Composable、グローバルは Pinia

L05 完。次は L06 TypeScript連携。

考えてみよう

既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。

Lv1: 基本 — useCounter

問題

// composables/useCounter.ts
import { ref } from "vue";

export function useCounter(initial = 0) {
  const count = ref(initial);
  const inc = () => count.value++;
  const dec = () => count.value--;
  const reset = () => count.value = initial;
  return { count, inc, dec, reset };
}
<script setup>
import { useCounter } from "@/composables/useCounter.js";

const { count, inc, reset } = useCounter(0);
</script>

複数 component で独立にカウント。

合格基準

  • 同じ hook を再利用
  • 状態は独立

Lv2: 応用 — useLocalStorage

問題

import { ref, watch } from "vue";

export function useLocalStorage<T>(key: string, initial: T) {
  const raw = localStorage.getItem(key);
  const value = ref<T>(raw ? JSON.parse(raw) : initial);

  watch(value, (v) => localStorage.setItem(key, JSON.stringify(v)), { deep: true });

  return value;
}

// 使う
const theme = useLocalStorage("theme", "light");

リロード後も値が保持。

合格基準

  • localStorage と同期
  • 複雑な object でも deep で watch

Lv3: 発展 — VueUse を使う

問題

pnpm add @vueuse/core
<script setup>
import { useMouse, useLocalStorage, useDebounceFn, useClipboard } from "@vueuse/core";

const { x, y } = useMouse();
const theme = useLocalStorage("theme", "light");

const debounced = useDebounceFn(() => console.log("debounced!"), 500);

const { copy, copied } = useClipboard();
</script>

<template>
  <p>Mouse: {{ x }}, {{ y }}</p>
  <button @click="theme = theme === 'light' ? 'dark' : 'light'">{{ theme }}</button>
  <input @input="debounced" />
  <button @click="copy('Hello!')">Copy</button>
  <span v-if="copied">Copied!</span>
</template>

VueUse の便利 composable 4種を試す。

合格基準

  • 4 composable とも動く
  • 「自前で書く前に VueUse」を理解

提出

submission.md に3問の回答を。