5. 状態
Vue における一大トピックスの一つに状態(state、ステート)管理があります。状態はコンポーネント内部で保持されるもので、画面上に表示されるデータなどアプリケーションが保持している状態を指しています。状態は props とは異なり、後から変更することができます(props は子コンポーネントでの変更が許可されていません)。
5.1 ref
コンポーネントにおける状態の保持を実現するのが、ref 関数です。ref は Vue の Composition API と呼ばれる関数群の一つで、他にも状態から計算される値を作る computed、副作用をもたらす watchEffect(7 章)などがあります。本章ではこのうち、ref と computed について述べていきます。
次の例は、ボタンをクリックすると状態である count の値を 1 増やすコンポーネントの例です。
Counter.vue
<script setup lang="ts">import { ref } from "vue";
const count = ref<number>(0);
// ボタンクリック時のイベントハンドラconst onClickButton = () => { count.value++;};</script>
<template> <div> <button @click="onClickButton">{{ count }}</button> </div></template>ref を用いる場合、次のように記述します。
const 状態変数 = ref<状態の型>(初期値);上記の例では、状態変数が count、状態の型が number、そして初期値が 0 に該当しています。初期値はこのコンポーネントが初めてレンダリングされる場合に使用される値です。なお、ref<T>() のように初期値を指定しないこともできます。その場合、状態の型は T | undefined となり、初期値は undefined になります。
@click="onClickButton" は「クリックされたら onClickButton 関数を呼ぶ」というディレクティブで、Day 1 の JavaScript で学んだ addEventListener("click", ...) に相当するものです1。ボタンをクリックすることで、イベントハンドラである onClickButton が呼び出され、その内部の count.value++ によって状態が更新されています。このように、状態を書き換えるだけで画面が自動的に更新されるのが Vue の強力な特徴の一つ、リアクティビティです。
5.1.1 .value の話(重要)
ここで、先ほどのプログラムをよく見てください。script ブロックでは count.value++ と書いているのに、template では {{ count }} と書いています。この違いはとても重要なので、丁寧に説明します。
ref(0) が返すのは、数値の 0 そのものではなく、0 が入った「箱」(Ref オブジェクト) です。箱の中身は count.value で読み書きします。Vue はこの箱の .value への読み書きを監視していて、値が変わったら画面を更新してくれる、という仕組みです。
まとめると、次の 2 つのルールを覚えてください。
scriptブロックの中では.valueを付けて読み書きするtemplateの中では.valueを付けずにそのまま書く(Vue が自動的に中身を取り出してくれます)
.value の付け忘れ(と、逆に template に .value を書いてしまうこと)は Vue 初心者が最初に踏む罠です。「画面が更新されない!」「エラーが出た!」となったら、まず .value を疑ってください。
5.2 演習 1
5.1 で示したプログラムに次のような行があります。
count.value++;ここの記述を次のようなプログラムに変更してその挙動を確かめてみましょう。
count++;すると、エディタ上で次のようなエラーが表示されるはずです(2 章で入れた拡張機能「Vue - Official」の働きです)。
Cannot assign to 'count' because it is a constant.count は const で宣言された「箱そのもの」なので、箱ごと置き換えることはできません、と怒られています。私たちが変えたいのは箱の中身なので、count.value++ が正解です。
注意したいのは、このミスをしても bun run dev のターミナルやブラウザの画面にはエラーが出ないことです(開発サーバは型チェックをしないためです)。「ボタンをクリックしても画面が変わらない」という一見不思議な状態になるだけなので、「画面が更新されない!」と思ったら、まずエディタのエラー表示を確認する癖をつけましょう。確認したら元に戻しておきます。
5.3 状態に配列やオブジェクトを用いる
次のプログラムは、ボタンをクリックすると入力された名前が順に表示されるコンポーネントの例です。
Names.vue
<script setup lang="ts">import { ref } from "vue";
const name = ref<string>("");const names = ref<string[]>([]);
// ボタンがクリックされた時のイベントハンドラconst onClickButton = () => { names.value.push(name.value);};</script>
<template> <div> <input type="text" v-model="name" /> <button @click="onClickButton">名前を追加</button> <p>{{ names.join(", ") }}</p> </div></template>新しい記法として v-model が登場しました。v-model は、入力欄の値と状態を双方向に結びつけるディレクティブです。ユーザが入力すると状態 name が自動的に更新され、逆にプログラム側で name.value を書き換えると入力欄の表示も変わります。これが 1 章の「難しい話」でいう双方向データバインディングです。input のほか、textarea や select にも使えます2。
さて、このプログラムは正しく動作するでしょうか。
答えは yes です。push で配列に要素を追加しているだけですが、Vue ではこれでちゃんと画面が更新されます。Vue のリアクティビティは、.value への代入だけでなく、配列やオブジェクトの中身の変化まで検知してくれるからです3。
5.4 computed
状態から自動的に計算される値を作りたいときは、computed 関数を使います。次の例は、カウンタの値の 2 倍を表示するコンポーネントです。
<script setup lang="ts">import { ref, computed } from "vue";
const count = ref<number>(0);
// count から自動的に計算される値const doubled = computed(() => count.value * 2);
// ボタンクリック時のイベントハンドラconst onClickButton = () => { count.value++;};</script>
<template> <div> <button @click="onClickButton">{{ count }}</button> <p>2 倍すると {{ doubled }} です</p> </div></template>computed には「値を計算して返す関数」を渡します。元になった状態(ここでは count)が変わると、doubled も自動的に計算し直され、画面も更新されます。
computed が返すのも ref と同じ「箱」なので、script 内で使うときは doubled.value です(ただし読み取り専用で、doubled.value = 10 のような代入はできません)。「状態 A から計算できる値 B」をわざわざ別の状態にせず computed で書くのが、Vue らしいきれいなコードのコツです。
5.5 子から親へ - defineEmits
props が「親 → 子」の通り道だとすると、「子 → 親」はどうすればいいのでしょうか。たとえば「子コンポーネントのボタンが押されたことを、親コンポーネントに知らせたい」という場面です4。
Vue では、子が イベントを発行(emit) し、親がそれを @イベント名 で受け取ります。button の @click を受け取るのと同じ要領で、自作のイベントを受け取れるようにするイメージです。
子コンポーネント側では defineEmits でイベントを宣言します(defineProps と同じく、import 不要のコンパイラマクロです)。
<script setup lang="ts">// 「submitTweet という名前で、name と text の 2 つの文字列を渡すイベント」の宣言const emit = defineEmits<{ submitTweet: [name: string, text: string];}>();
const onClickButton = () => { // イベントを発行する emit("submitTweet", "名前", "ツイート内容");};</script>親コンポーネント側では、ケバブケースにした @submit-tweet でイベントを受け取ります。
<template> <CreateTweet @submit-tweet="onSubmitTweet" /></template>onSubmitTweet は親側で定義する関数で、emit に渡した引数(name と text)を受け取ることができます。
5.6 演習 2
src/components/CreateTweet.vueを作成し、一つのtextarea(ツイート内容用)と一つのinput(名前用)、一つのbuttonを 一つのdivで囲んだコンポーネントを作成して下さい。button要素の値の記述を変更し、textareaに入力された文字数が 0 字以上 140 字以下であれば「ツイート」と、そうでなければ「送信できません」と表示されるようにプログラムを変更して下さい。defineEmitsでsubmitTweetイベント(名前とツイート内容の 2 つの文字列を渡す)を宣言し、ツイートボタンがクリックされたときに発行されるようにして下さい。App.vueで作成したCreateTweetコンポーネントを呼び出し、表示を確認して下さい。
ヒント
- 要素の値には式を記述することができます(マスタッシュ構文の中に三項演算子が書けます)。
- 入力されている名前と入力されているツイート内容用の 2 つの状態が必要です。それぞれ
v-modelで結びつけましょう。 - 「送信できるかどうか」は入力内容から計算できる値なので、
computedにするのがきれいです。text.value.lengthで文字数が取れます。 - イベントの宣言は次のようになるはずです。
const emit = defineEmits<{
submitTweet: [name: string, text: string];
}>();
Footnotes
-
@はv-bind:に対する:と同じく、v-on:の省略形です。@input、@submit、@keydownなど、HTML のイベントは一通り使えます。 ↩ -
React では
value={name}とonChange={...}を両方書いて自分で状態を更新する必要がありましたが、Vue ではv-model1 つで済みます。 ↩ -
React 編を読んだ人向けの補足:React では
names.push(name)では再レンダリングされず、setNames([...names, name])のように新しい配列を作る必要がありました(React 編 5 章参照)。Vue は Proxy という JavaScript の仕組みで中身の変更を検知しているため、pushのような破壊的な操作もそのまま反映されます。このあたりの思想の違いは面白いので、余裕があれば「React イミュータブル Vue リアクティビティ」などで調べてみてください。 ↩ -
React では、親が定義した関数を props として子に渡し、子がそれを呼び出すことでこれを実現していました(React 編 5 章の演習参照)。 ↩