🌎

React HooksとVue Composition APIの比較

2022/08/15に公開
1

概要

Vueの作者であるEvan You氏は、Vueのデフォルトバージョンが2022年2月7日以降3.x系に切り替わる事を開発者ブログにおいて発表しました。

それに伴い、Vue2.x系の最後のマイナーバージョンであるVue2.7が2022年7月1日にリリースされ、こちらの LTS (long-term support)はリリースから18ヶ月であることから、Vue2.x系へのサポートは2023年12月を持って完全に打ち切られる事となりました。

以下の記事でも語られている通りVue2とVue3では破壊的変更が多く、移行コストが格段に高いことから、多くの開発チームがVue3.x系への移行を頑張るかReact等の他のフレームワークへの乗り換えをするかの選択を迫られています。

https://medium.com/js-dojo/vue-3-was-a-mistake-that-we-should-not-repeat-81cc65484954

3.x系へ移行すべきかフレームワークを乗り換えるべきかの是非についてはこの記事では触れませんが、Vueの破壊的変更に伴って多くのフロントエンド開発者が一定の学習コストを払わなければならないことには変わりありません。

筆者は本業ではVue3.2、副業の業務委託先ではReact18.2を使って開発をしているので、両フレームワークについて完全に理解した[1]程度には親しんでいます(マサカリ予防)。

そこで本記事では、Vue3.x系の目玉機能であるComposition APIと、関数型コンポーネントReactの目玉機能であるHooksに焦点を絞って、両者の違いや利点について紹介したいと思います。

歴史的経緯

React Hooksが最初に登場したのはReact Conf 2018に遡ります。Hooks登場以前にReactで異なるコンポーネント間で状態を伴うロジックを使い回すにはHOC(higher-order components)render propsパターンを使用する必要があり、それらはコンポーネントを大量の抽象化層で囲うWrapper Hellという問題点がありました。

const withLogic = (Component: () => JSX.Element) => {
    return class extends React.Component {
        render(): React.ReactNode {
            return <Component {...this.props} />
        }
    }
}
const Component = () => <div>Hello</div>
export default withLogic(Component)
// ^上記のラッパーコンポーネントが何層にも重なる

v16.8におけるReact Hooksの登場により、コンポーネントの階層構造を変更する事なく、状態を伴うロジックが再利用可能になった事でUIとロジックを疎結合に保つ事ができるようになりました。加えて、componentDidMountomponentWillUnmountに代表される各ライフサイクルメソッドに散らばった関連ロジックは、useEffectHookによって凝集度を高める事ができるようになりました。

2系のVueや2016年以前のReactにもMixinと呼ばれる異なるコンポーネント間でロジックを再利用するための機能が提供されていましたが、Mixinには以下のような問題点がありました。

  • 暗黙的挙動のため、定義元に辿らなければ処理を把握できない
  • 名前の衝突がある
  • TypeScriptによる型の恩恵を享受できない

Hooksの登場を受け、Vueの作者であるEvan You氏でさえも自身のTwitterにおいて
Hooksは客観的にみてMixinよりも優れている」と認める形になりました。そして、「VueのAPIを適切に補完する形で、Vueユーザーにも同様の”パワー”を提供する方法を探ろうと思う」という発言を残しました。

https://twitter.com/youyuxi/status/1056673771376050176?s=20&t=-Er3oV9QHo-NbkVwfLKPPg

こうしてEvan氏の宣言通りReact Hooksにインスパイアされる形で生まれたのが、Composition APIというAPIでした。しかしEvan氏が至るところで述べているように、React HooksとComposition APIは根本的に全く異なるAPIであり、筆者もそれに同意します。

その最大の相違点は、状態をMutableに管理するかImmutableに管理するか、という点です。

これについて、次章以降で詳しく掘り下げる事とします⏯

基礎知識

本題に入る前にこの章ではまずHooksとComposition APIに関する基礎知識を紹介します。

基本的な使い方の説明に留めているので、両APIを少しでも触れた事がある方はこの章を飛ばしてさっそく次章に進んでいただいても構いません。

状態の宣言

React

const [count, setCount] = useState(0);

Vue

<script setup lang="ts">
import { ref } from "vue";
const count = ref(0)
</script>

ReactにおいてはuseStateにはプリミティブ値とオブジェクト双方を引数に取る事ができますが、Vueでは一般的にプリミティブ値の場合はrefを使用します。オブジェクトの場合はreactiveを使用します。

<script setup lang="ts">
import { reactive } from "vue";
const object = reactive({})
</script>

先ほど「一般的に」と述べましたが、refはプリミティブ値以外の値も引数に受け取る事ができます。反対にreactiveはオブジェクトのみを受け取ります。ただし、refでオブジェクトを扱う場合は階層が深い部分ではリアクティブにならない点を注意しなければなりません。

refの返り値の型はRef<T>である(故に値へのアクセスには.valueを介さなければならない)一方でreactiveの返り値の値は<T>となります。

状態の更新

React

import { useState } from "react";

function Example() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount((count) => count + 1)}>Click me</button>
    </div>
  );
}

Vue

<template>
  <p>You clicked {{ count }} times</p>
  <button @click="count++">Click me</button>
</template>

<script setup lang="ts">
import { ref } from "vue";

const count = ref(0);
</script>

ReactのuseStateは[S, Dispatch<SetStateAction<S>>]型のタプルを返し、それぞれ現在の状態、状態を更新するSetter関数となっています。SetStateActionの引数はS | ((prevState: S) => S)となっており、直接更新する値を渡すだけでなく、現在の状態を引数に取る更新用の関数を渡すこともできます。

Reactにおける状態はイミュータブルで、値を直接更新することはできません。setStateによって値が更新されると、コンポーネントは再レンダリングされ、メモ化していない限りは全く新しいオブジェクトに変換されます。この理由として、ReactはObject.isを用いて値の等価性を判定しているので、オブジェクトのプロパティをミュータブルに更新してもそれらは等価と見做されコンポーネントを再レンダリングできないからです。

facebook/react/packages/react-reconciler/src/ReactFiberHooks.new.js
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
) {
  // 中略
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

一方でVueではsetCountのようなSetter関数は用意されておらず、直接状態のvalueにアクセスしてミュータブルに値を更新します。また、先ほどrefオブジェクトには.valueプロパティでアクセスすると述べましたが、Template上では直接値にアクセスする事ができます。

副作用の実行

React

import { useState, useEffect } from "react";

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount((count) => count + 1)}>Click me</button>
    </div>
  );
}

Vue

<template>
  <p>You clicked {{ count }} times</p>
  <button @click="count++">Click me</button>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";

const count = ref(0);
watch(
  count,
  (currentCount, previousCount) =>
    (document.title = `You clicked ${currentCount} times`)
);
</script>

Reactで副作用を実行するにはuseEffectを使用します。useEffectでは第2引数に依存配列を指定し、依存配列内の状態が変化した際に第1引数のコールバックが発火します。

Vueにもwatchというほぼ同様の動作をするのAPIが用意されていて、こちらは第1引数に依存にする状態を引数に取り、状態が変化した際にコールバックが発火します。また、コールバックでは第1引数に変化後の状態を返し、第2引数では変化前の状態が返されます。

ここで「ほぼ」という言い回しをしましたが、上記コードは一見同じように動作するように見えて、実際には少しだけ異なる挙動を見せます。

上記コードでは副作用発火時にdocument.titleを動的に変更しています。React版ではページを開いた段階で以下のようにタイトルが変更されているので、コンポーネントマウント時に副作用hookが発火している事がわかります。

React

反対に、Vue版ではページを開いた段階ではdocument.titleは変更されず、Click meボタンを押すまでdocument.titleが初期値のままになっています。

Vue watch

この挙動の原因は、Vueのwatchは遅延実行を採用しているからです。すなわち、第1引数に指定された依存変数(または配列)が変化するまでは副作用は実行されません。この挙動は、watchの第3引数WatchOptionimmediateを指定することでuseEffectのように即時実行に変更することも可能です。

また、watchの代わりにwatchEffectを使用することで即時実行させることも可能です。watchEffectwatchと違い依存変数を引数に取らず、関数内で使用されるリアクティブな状態が更新される度にコールバックを発火させます。

<template>
  <p>You clicked {{ count }} times</p>
  <button @click="count++">Click me</button>
</template>

<script setup lang="ts">
import { ref, watchEffect } from "vue";

const count = ref(0);
watchEffect(() => (document.title = `You clicked ${count.value} times`));
</script>

副作用のクリーンアップ

React

useEffect(() => {
    const controller = new AbortController();
    fetch(url, { signal: controller.signal }).then((resp) => ...)
    return () => controller.abort();
  }, [url]);

Vue

<script setup lang="ts">
watchEffect((onCleanUp) => {
  const controller = new AbortController();
  onCleanUp(() => controller.abort());
  fetch(url.value, {signal: controller.signal}).then(resp => ...)
});
</script>

ReactのuseEffectの第1引数コールバック関数は任意でクリーンアップ関数を返します。例ではAbortControllerを用いて依存配列内に指定したurlが変わる度に実行中のPromiseを中断しています。

一方でVueのwatchEffectはコールバックの引数にクリーンアップ関数を受け取ります。上記コードはuseEffect版と同様の処理を行っています。注意点として、watchEffect内でクリーンアップ関数はPromiseを返す関数より前に定義する必要があります。

watchEffectを用いた上記コードでは、潜在的な問題点が1つあります。useEffectの場合は監視対象を依存配列内の値に限定し、監視対象が変化した場合のみクリーンアップ関数を呼び出す事ができますが、watchEffectはコールバック関数内で使用される全てのリアクティブ値の変更に応じて発火してしまいます。

監視対象を特定の値に絞りたい場合は、watchを使用します。Vueの型定義を見てみると、watchのコールバックは第3引数にクリーンアップ関数を返しているので、watchに依存変数を指定する形でクリーンアップ関数を呼び出す事ができます。

runtime-core.d.ts
export declare function watch<T extends object, Immediate extends Readonly<boolean> = false>(source: T, cb: WatchCallback<T, Immediate extends true ? T | undefined : T>, options?: WatchOptions<Immediate>): WatchStopHandle;

export declare type WatchCallback<V = any, OV = any> = (value: V, oldValue: OV, onCleanup: OnCleanup) => any;

本題:HooksとComposition APIの違い

HooksとComposition APIの基本的な使い方を学習したところで、いよいよ両者の違いについて深堀りしていこうと思います。

Hooks

Reactユーザーには既に周知の通り、Reactではコンポーネントが再レンダリングされる度に内部の関数や変数を再生成します。そのため、Reactでは子コンポーネントの不要な再レンダリングを避けるために使用者側がuseMemouseCallbackを使用してオブジェクトをメモ化することで子コンポーネントに渡すpropsの同一性を維持し、パフォーマンス問題を防ぐ必要があります(子コンポーネントはReact.memoで覆う必要性があります)。

この際、メモ化関数の依存配列に適切な値をいれなければ、古い状態を参照してしまうStale Closureという問題があります。これはJavaScriptでは関数が作成されたタイミングでクロージャが作成されるので、関数作成時のレキシカルスコープ内に存在する変数を保持する事が原因です。

つまり、メモ化した関数の依存配列に適切なオブジェクトを渡さなければ関数内のオブジェクトは古いクロージャの中に閉じ込められたままになってしまいます。以下の例ではcountUpの依存配列が空になっているために、関数内で使用しているcountの値は関数作成時(すなわちコンポーネントマウント時)の状態から変化しません。

function Example() {
  const [count, setCount] = useState(0);
  const countUp = useCallback(() => {
    setCount(count + 1);
  }, []);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={countUp}>Click me</button>
    </div>
  );
}

上記より、Hooksは親からの入力の差分を監視するメモ化関数であると言えます。差分が発生した時点でコンポーネントを再生成し、それ以外では入力を保持する事で状態を最新に保っています。

ところで、Reactがメモ化によって差分計算を行っていることは分かりましたが計算コストには一体どれくらい掛かっているのでしょうか。これについては、React CoreチームのAndrew Clark氏によるReact Fiberのアーキテクチャを説明したリポジトリや、元React Coreチームの1人であるSebastian Markbåge氏によるIssueが参考になります。

https://github.com/acdlite/react-fiber-architecture

https://github.com/facebook/react/issues/7942?source=post_page---------------------------#issue-182373497

通常プログラムの実行を追尾するにはCall Stackが末尾再帰的に行われます。Reactも同様に末尾再帰的にDOMツリーを走査し、DOMの更新をUIに同期的に反映させていたのですが、これには処理の停止ができず処理中に画面がフリーズするなどのパフォーマンス問題を抱えていました。その後Fiberという新たに導入されたアルゴリズムではUIへの変更は必ずしも同期的でなくても良いという理由で、更新系の処理単位を単方向線形リスト[2]に独立させました。

ちなみにHooksも内部的な処理単位はFiberとなっています。以下がReactソースコード内に記載されていたコメントです。

// Hooks are stored as a linked list on the fiber's memoizedState field. The
// current hook list is the list that belongs to the current fiber. The
// work-in-progress hook list is a new list that will be added to the
// work-in-progress fiber.

Reactのソースコード上では、以下の箇所で線形リストが使われているのが分かります。初回レンダリングではmountWorkInProgressHookの最初のif文が呼ばれ、それ以降のレンダリングではworkInProgressHook.nextにhookを繋げています。このmountWorkInProgressHook関数は各Hookを生成するmountXXX系の関数全てに使われているので、内部実装上も上記コメントと辻褄が合います。

packages/react-reconciler/src/ReactFiberHooks.new.js
function mountWorkInProgressHook(): Hook {
  // 中略
  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

// useStateはmountStateの実行結果を戻り値に使用している
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook();
  // 中略
  hook.memoizedState = hook.baseState = initialState;
  // 中略
  return [hook.memoizedState, dispatch];
}

Hooksが単方向線形リストで実装されていることを考えると、ReactにおいてHooksが呼ばれる順番が重要で、条件分岐内でHooksを呼び出せない理由にも納得がいきます。コンポーネント初回マウント以降は再レンダリングの度に過去に作成された線形リストを順番通り走査して差分検出を行うので、もし条件分岐の中でHooksが呼び出された場合はレンダリングの度にNodeの数が変わってしまうという問題を引き起こします。Reactユーザーであれば誰もが見た事のあるRendered fewer hooks than expected or Rendered more hooks than during the previous render.というエラーの理屈がそれに当たります。

Composition API

さて、次はVueのComposition APIを見てみましょう。

Hooksと違い、Composition APIではコンポーネントのインスタンス生成時に一度だけsetup()関数を呼び出します。これによってHooksのStale Closureの様な問題は起きません。また、Hooksの様に条件分岐内で呼び出してはいけないといった制約もありません。それではVueはどのようにして状態の更新を検知しているのでしょう。

まずはComposition APIにおける状態の管理方法を見てみましょう。、Composition APIにおける状態はtargetMap > depsMap > depという三層構造で管理されています。

出典:https://www.vuemastery.com/courses/vue-3-reactivity/vue3-reactivity

targetMapは状態が依存しているオブジェクトの一覧を保持しているオブジェクトで、WeakMap<any, KeyToDepMap>型となっています。WeakMapはMap同様key/valueペアですが、keyがオブジェクトに弱い参照を持ち自動でガベージコレクションをしてくれます。ここでkeyに格納されるのが状態にあたりますが、使われなくなったタイミングでメモリが解放されます。

vuejs/core/packages/reactivity/src/effect.ts
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()

depsMapWeakMapのジェネリクスであるKeyToDepMapにあたります。こちらはただのMapで、キーに状態が持つプロパティ名、バリューにはdepと呼ばれるオブジェクトが保管されます。depは内部にeffectと呼ばれる関数を持ち、この関数は値の変更に応じて再実行する処理を格納しています。

depsMaptrackという関数内で生成されており、depsMap.set(key, (dep = createDep()))という形でdepを格納します。

vuejs/core/packages/reactivity/src/effect.ts
export function track(target: object, type: TrackOpTypes, key: unknown) {
  if (shouldTrack && activeEffect) {
    let depsMap = targetMap.get(target)
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()))
    }
    let dep = depsMap.get(key)
    if (!dep) {
      depsMap.set(key, (dep = createDep()))
    }

    const eventInfo = __DEV__
      ? { effect: activeEffect, target, type, key }
      : undefined

    trackEffects(dep, eventInfo)
  }
}

リアクテビティを実現するためには先ほどtrack関数によって格納されたeffectを呼び出す必要がありますが、この関数を実行する役割はtriggerEffectという関数が担っています。つまり、状態をリアクティブに保つには状態を呼び出すタイミングでtrack関数を、状態が更新されたタイミングでtriggerEffect関数を呼び出せば良いということです。

状態の呼び出し・更新に応じてtracktriggerEffectを呼び出すには、ProxyというES6から導入された機能によって実現する事ができます。Proxyとはオブジェクトを別のオブジェクトでラップし、第2引数にハンドラ関数を与える事でget/set操作に独自処理を追加する事ができるオブジェクトです。Vueの内部では以下の様な形で実装されています。

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      let result = Reflect.get(target, key, receiver);
      track(target, key);
      return result;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let result = Reflect.set(target, key, value, receiver);
      if (result && oldValue !== value) {
        triggerEffect(target, key);
      }
      return result;
    },
  };
  return new Proxy(target, handler);
}

つまり、ProxyこそがVue3においてリアクティブな状態管理を実現しているのです。(Vue3がES6から提供される機能を利用できる理由は、Vue3がInternet Explorerをサポートしていないからです。)

イミュータブルなHooksとミュータブルなComposition API

さて、ここまでで一旦HooksとComposition APIの設計上の違いについて見てきました。内部実装を交えながら、Hooksではメモ化された線形リストである一方、Composition APIではProxyが用いられていることが分かりました。

内部的な違いは分かりましたが、我々ライブラリの利用者が意識することは何でしょうか?以下の様な<input />に入力した文字列の長さを画面に表示するだけのシンプルな実装を見ながら両者の挙動の違いを見てみましょう。

React

function Input() {
  const [input, setInput] = useState("");
  const inputLength = input.length;

  return (
    <div className="App">
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <div>{inputLength}</div>
    </div>
  );
}

Vue

<template>
  <div>
    <input type="text" v-model="input" />
    <div>{{ inputLength }}</div>
  </div>
</template>
<script setup lang="ts">
import { ref } from "vue";

const input = ref("");
const inputLength = input.value.length;
</script>

直感的には、両者とも<input />に文字を入力するたびにView上でinputLengthの値が更新されそうですが、実際にはそうではありません。Reactの実装では期待通り文字を入力するたびにinputLengthが更新されますが、Vueの実装では文字を入力してもinputLengthの値が0のままになります。

何度も述べてきた通りReactでは状態が更新されるたびにコンポーネントの再生成が行われ、メモ化していない限り状態は全く新しいオブジェクトに生まれ変わります。上記の実装ではonChangeが行われる度にコンポーネントが再生成されます。この挙動によってinputLengthの値は常にinputの値と同期されています。

反対にVueではsetupは一度のみ呼ばれるので、状態はイミュータブルではありません。そのため、状態の変化を反映するには先述したtriggerEffectを発火させなければなりませんが、上記のinput.value.lengthではeffect関数がトリガーされていないので状態が変更されません。

ここで必要になるのが、算出プロパティ(computed)と呼ばれる状態の変更を追尾しする仕組みです。computedのコールバックにrefオブジェクトを入れることで、状態が更新される度に内部的にtriggerEffectが実行されます(内部実装の詳しい説明は【Vue.js】computedを実装して仕組みを理解するが参考になりました。)。また、computedでは内部的に計算結果をキャッシュしているので依存データが変わらない限りは再計算がされません。

なので、上記実装は以下のように書き換えることで期待通りの挙動になります。

const inputLength = computed(() => input.value.length);

Vueのミュータブルな状態管理システムにおいて、状態をリアクティブに保つためには明示的にeffecttriggerさせなければならないという欠点があります。しかしComposition APIはProxyベースのバニラなJavaScriptで構成されているので、Hooksのように手動で依存配列を指定する必要もなければ、Reactのコンポーネント内でしか使えないと言った制約も抱えていないという点では優れていると言えるでしょう。

結論

React HooksとVue Composition APIは両方ともステートフルなロジックをコンポーネントから分離し、抽象化を可能にしました。両者とも同じ様な機能を提供していますが、状態の変化をどう扱うか、という点で両APIは大きく異なります。

Hooksでは状態をイミュータブルに保つことを強制し、専用のsetter関数を提供しましたが、Composition APIでは値をrefreactiveで覆うことで状態をミュータブルに更新するこができました。その結果ReactではuseMemouseCallbackで計算をメモ化しましたが、Composition APIではweakMapProxyを用いることでライブラリ側で効率的に依存関係を更新することができました。

Virtual DOM更新以外の副作用実行に関しては副作用と状態の依存関係をフレームワーク側で推測できないため、どちらも開発者に依存する形になっています。この辺はAPIが違えどそこまで変わりません。

どちらのAPIが優れているという話ではなく、両APIは異なるメンタルモデルを持っているというのだと感じています。最近のフロントエンド界隈は専らReact一強だったので、Vue3に導入された新たなAPIはこれからのフロントエンド界をさらに盛り上げてくれるのではないかと期待しております。

終わりに

大変長くなりましたが、最後まで読んでいただき有難うございました。

筆者は元々React大好き人間で、Vue2時代の記憶からあまりVueに良い印象を持っていませんでしたが、Vue3 + Composition APIにはとても大きな可能性を感じています。

最後に少しだけ宣伝になりますが、私が所属するTHECOOではFaniconをより良いサービスにするための仲間を募集しています。

弊社では、つい先日Vue2からVue3へのマイグレーションが完了し、只今絶賛既存コードのVue3移行や新規機能開発をゴリゴリ進めています。

Vue3 + Composition APIを使ってサービス開発をやっていき💪たい方、カジュアルに情報交換も可能ですのでお気軽にご連絡くださいませ。(Twitterもやっているのでそちらからも連絡お待ちしております)

THECOO 採用情報
https://hrmos.co/pages/thecoo

THECOO 会社情報
https://thecoonotion.notion.site/THECOO-1949fca9a7f14cef81dd8c16843ba62f

脚注
  1. つまり何も分かっていないということ。 ↩︎

  2. データとポインタが入ったNodeと呼ばれる要素をポインタ数珠繋ぎにしたデータ構造の事。ちょうどこの記事を書いた同じ日に連結リストのちょっとした問題集という記事が挙げられていました。フロイドの循環検出アルゴリズム、ぼくの一番好きなアルゴリズムです。 ↩︎

Discussion

山下 裕一朗山下 裕一朗

反対にreactiveはオブジェクトのみを受け取ります。ただし、refでオブジェクトを扱う場合は階層が深い部分ではリアクティブにならない点を注意しなければなりません。

本題と関係なくて申し訳ございません。
公式ドキュメントを確認して、実際に動作確認してみたのですが、ref に オブジェクト をアサインしても深くリアクティブになっているように思いました。何か読み間違えていたらすみません。

公式ドキュメント
https://vuejs.org/api/reactivity-core.html#ref

If an object is assigned as a ref's value, the object is made deeply reactive with reactive(). This also means > if the object contains nested refs, they will be deeply unwrapped.

To avoid the deep conversion, use shallowRef() instead.

確認に使用したコード

<script setup>
import { ref } from 'vue'

const msg = ref({message: 'Hello World!', items: []})

const add = () => {
  msg.value.items.push('item');
}
</script>

<template>
  <h1>{{ msg }}</h1>
  <input v-model="msg.message">
  <button @click="add">add</button>
</template>