Vueのh関数とは、画面の部品を表すオブジェクト(vnode、仮想ノード)を作る関数で、render関数(レンダー関数)とは、そのvnodeを返す関数のことです。 テンプレートはビルド時にレンダー関数へ変換されるので、h関数で書くことは「テンプレートが変換された後の形を自分で書く」ことにあたります。JSXは、そのh関数の呼び出しをタグに近い見た目で書ける記法です。

この記事では、同じ画面をテンプレート・h関数・JSXの3通りで作り、出力されるHTMLと操作後の結果が一致することをブラウザで確かめたうえで、書き方を3列の対応表にまとめました。 ViteでJSXを動かす設定、script setupの中でh関数を使う方法、関数型コンポーネントの型付けと実際の型エラー、同じvnodeを使い回したときに表示が更新されなくなる例まで、貼って動くコードと出力で確認できます。

確認環境:Ubuntu 24.04、Node.js 22.22.2、Vue 3.5.43、Vite 8.3.2、@vitejs/plugin-vue 6.0.9、@vitejs/plugin-vue-jsx 5.1.6、TypeScript 7.0.2。ブラウザはPlaywright 1.56.0から起動したChromium 141です。

テンプレート・h関数・JSXの対応早見表

すべて、後半に載せたファイル(BoardTemplate.vue、BoardH.js、BoardJsx.jsxなど)で動かした書き方です。

やりたいこと テンプレート h関数 JSX
要素と属性 <div class="board"> h('div', { class: 'board' }, [...]) <div class="board">
文字の埋め込み {{ memo }} `メモ: ${memo.value}` {memo.value}
v-if / v-else <p v-if="tasks.length === 0"> と <p v-else> 条件 ? h('p', ...) : h('p', ...) {条件 ? <p>…</p> : <p>…</p>}
v-for <li v-for="t in tasks" :key="t.id"> tasks.value.map((t) => h('li', { key: t.id }, t.title)) {tasks.value.map((t) => <li key={t.id}>{t.title}</li>)}
v-on @click="fn" { onClick: fn } onClick={fn}
イベント修飾子 @submit.prevent="add" { onSubmit: withModifiers(add, ['prevent']) } onSubmit={withModifiers(add, ['prevent'])}
キー修飾子 @keyup.esc="fn" { onKeyup: withKeys(fn, ['esc']) } onKeyup={withKeys(fn, ['esc'])}
v-model(input) v-model="text" { value: text.value, onInput: (e) => { text.value = e.target.value } } v-model={text.value}
v-model(コンポーネント) <TextField v-model="memo" /> h(TextField, { modelValue: memo.value, 'onUpdate:modelValue': (v) => { memo.value = v } }) <TextField v-model={memo.value} />
v-show v-show="showHelp" withDirectives(h('p', ...), [[vShow, showHelp.value]]) v-show={showHelp.value}
カスタムディレクティブ v-focus withDirectives(h('input', ...), [[vFocus]]) v-focus(directivesオプションに登録)
テンプレート参照 ref="inputEl" と useTemplateRef('inputEl') { ref: 'inputEl' } と useTemplateRef('inputEl') ref="inputEl" と useTemplateRef('inputEl')
名前付きスロットを渡す <template #header>タスク一覧</template> h(Card, props, { header: () => 'タスク一覧' }) <Card>{{ header: () => 'タスク一覧' }}</Card>
スコープ付きスロットを渡す <template #item="{ task }"> { item: ({ task }) => task.title } {{ item: ({ task }) => task.title }}
デフォルトスロットを渡す タグの中に書く { default: () => h('small', info.value) } {{ default: () => <small>{info.value}</small> }}

h関数とJSXでは、refの中身は .value を付けて読みます。 テンプレートのような自動の取り出しは行われません。

どれで書くかの判断表

場面 向いている書き方 理由
ふつうの画面・フォーム テンプレート HTMLに近い形で書け、読みやすい
タグ名や階層をデータから組み立てる(見出しレベル、ツリー、表のセル) h関数 JavaScriptの式でそのまま組み立てられる
受け取ったスロットを加工して並べ直す h関数 スロットの戻り値(vnodeの配列)を直接扱える
条件分岐やループが多い小さな部品を、型つきで書きたい JSX / TSX propsの型チェックがタグの位置で効く
ライブラリ用の薄いラッパー h関数または関数型コンポーネント ビルド設定を足さずに書ける

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue h関数とrender関数の使い方|テンプレート対応表とJSX」

h関数の基本:引数の形と出力

h関数は h(タグまたはコンポーネント, props, 子) の形で呼びます。propsと子は省略できます。Node.jsでサーバー用のレンダラーを使い、いくつかの形を文字列にして確かめました。

// hforms.mjs(node hforms.mjs で実行)
import { h, createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'

const show = async (label, vnode) =>
  console.log(label.padEnd(26), await renderToString(createSSRApp({ render: () => vnode })))

await show('タグだけ', h('div'))
await show('props', h('div', { id: 'box' }))
await show('子が文字列', h('p', 'こんにちは'))
await show('props と子', h('p', { class: 'lead' }, 'こんにちは'))
await show('子が配列', h('ul', [h('li', 'A'), h('li', 'B')]))
await show('class に配列とオブジェクト', h('p', { class: ['a', { b: true, c: false }] }, 'x'))
await show('style にオブジェクト', h('p', { style: { color: 'red', fontSize: '14px' } }, 'x'))
await show('innerHTML', h('p', { innerHTML: '<b>太字</b>' }))
await show('属性を強制(^)', h('input', { '^value': 'abc' }))
await show('v-for にあたる map', h('ol', [1, 2, 3].map((n) => h('li', { key: n }, `${n}番`))))
タグだけ                    <div></div>
props                       <div id="box"></div>
子が文字列                  <p>こんにちは</p>
props と子                  <p class="lead">こんにちは</p>
子が配列                    <ul><li>A</li><li>B</li></ul>
class に配列とオブジェクト  <p class="a b">x</p>
style にオブジェクト        <p style="color:red;font-size:14px;">x</p>
innerHTML                   <p><b>太字</b></p>
属性を強制(^)             <input value="abc">
v-for にあたる map          <ol><li>1番</li><li>2番</li><li>3番</li></ol>
引数 書けるもの 例
第1引数 タグ名の文字列、またはコンポーネント 'div'、Card
第2引数(省略可) 属性・プロパティ・イベントをまとめたオブジェクト { class: 'lead', onClick: fn }
第3引数(省略可) 文字列、vnodeの配列、スロットのオブジェクト 'こんにちは'、[h('li', 'A')]、{ default: () => ... }
  • classとstyleは、テンプレートの :class、:style と同じく配列やオブジェクトを渡せます。
  • イベントは on で始まるキャメルケースのキーで書きます(onClick、onUpdate:modelValue)。
  • コンポーネントはimportした変数をそのまま第1引数に渡します。

render関数を置く場所

レンダー関数の置き方は2通りあります。どちらも今回の確認で動かした形です。

置き方 書き方 向いている場面
setupから関数を返す setup() { return () => h('div', ...) } Composition APIの状態(ref)をそのまま使う
renderオプション { render: () => h('div') } 状態を持たない単純な出力

同じ画面を3通りで書く(そのまま動く一式)

タスクを追加できる小さな画面を、テンプレート・h関数・JSXで1つずつ作ります。使う機能は、v-if、v-for、v-on(修飾子つき)、v-model(inputとコンポーネント)、スロット(名前付き・スコープ付き・デフォルト)、ディレクティブ(v-showとカスタム)、テンプレート参照です。

共通の部品

<!-- src/Card.vue:スロットを3種類持つ部品 -->
<script setup>
defineProps({ tasks: Array })
</script>

<template>
  <section class="card">
    <header><slot name="header" /></header>
    <ul v-if="tasks.length">
      <li v-for="(task, i) in tasks" :key="task.id"><slot name="item" :task="task" :index="i" /></li>
    </ul>
    <slot />
  </section>
</template>
<!-- src/TextField.vue:v-model を受け取る部品 -->
<script setup>
const model = defineModel()
</script>

<template>
  <input class="field" :value="model" @input="model = $event.target.value" />
</template>
// src/focus.js
// 表示されたら data-focused を付けてフォーカスするディレクティブ
export const vFocus = {
  mounted(el) {
    el.dataset.focused = 'yes'
    el.focus()
  },
}

テンプレート版

<!-- src/BoardTemplate.vue -->
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue'
import Card from './Card.vue'
import TextField from './TextField.vue'
import { vFocus } from './focus.js'

const tasks = ref([{ id: 1, title: '見積もり' }])
const text = ref('')
const memo = ref('')
const showHelp = ref(false)
const inputEl = useTemplateRef('inputEl')
const info = ref('')

function add() {
  if (!text.value) return
  tasks.value.push({ id: tasks.value.length + 1, title: text.value })
  text.value = ''
}
onMounted(() => { info.value = `ref: ${inputEl.value.tagName}` })
</script>

<template>
  <div class="board">
    <form @submit.prevent="add">
      <input ref="inputEl" v-model="text" v-focus class="new" @keyup.esc="text = ''" />
      <button type="submit">追加</button>
    </form>
    <TextField v-model="memo" />
    <p class="memo">メモ: {{ memo }}</p>
    <button class="help-btn" @click="showHelp = !showHelp">ヘルプ</button>
    <p v-show="showHelp" class="help">Enterで追加できます</p>
    <p v-if="tasks.length === 0" class="empty">タスクはありません</p>
    <p v-else class="count">{{ tasks.length }}件</p>
    <Card :tasks="tasks">
      <template #header>タスク一覧</template>
      <template #item="{ task, index }">{{ index + 1 }}. {{ task.title }}</template>
      <small>{{ info }}</small>
    </Card>
  </div>
</template>

h関数版

// src/BoardH.js
import { h, ref, useTemplateRef, onMounted, withModifiers, withKeys, withDirectives, vShow } from 'vue'
import Card from './Card.vue'
import TextField from './TextField.vue'
import { vFocus } from './focus.js'

export default {
  setup() {
    const tasks = ref([{ id: 1, title: '見積もり' }])
    const text = ref('')
    const memo = ref('')
    const showHelp = ref(false)
    const inputEl = useTemplateRef('inputEl')
    const info = ref('')

    function add() {
      if (!text.value) return
      tasks.value.push({ id: tasks.value.length + 1, title: text.value })
      text.value = ''
    }
    onMounted(() => { info.value = `ref: ${inputEl.value.tagName}` })

    // setup から関数を返すと、それがレンダー関数になる
    return () =>
      h('div', { class: 'board' }, [
        // @submit.prevent → withModifiers
        h('form', { onSubmit: withModifiers(add, ['prevent']) }, [
          // v-focus → withDirectives、v-model → value と onInput、ref → ref 属性
          withDirectives(
            h('input', {
              ref: 'inputEl',
              class: 'new',
              value: text.value,
              onInput: (e) => { text.value = e.target.value },
              onKeyup: withKeys(() => { text.value = '' }, ['esc']),
            }),
            [[vFocus]],
          ),
          h('button', { type: 'submit' }, '追加'),
        ]),
        // コンポーネントの v-model → modelValue と onUpdate:modelValue
        h(TextField, { modelValue: memo.value, 'onUpdate:modelValue': (v) => { memo.value = v } }),
        h('p', { class: 'memo' }, `メモ: ${memo.value}`),
        h('button', { class: 'help-btn', onClick: () => { showHelp.value = !showHelp.value } }, 'ヘルプ'),
        // v-show → withDirectives と vShow
        withDirectives(h('p', { class: 'help' }, 'Enterで追加できます'), [[vShow, showHelp.value]]),
        // v-if / v-else → 三項演算子
        tasks.value.length === 0
          ? h('p', { class: 'empty' }, 'タスクはありません')
          : h('p', { class: 'count' }, `${tasks.value.length}件`),
        // スロット → 関数を持つオブジェクト
        h(Card, { tasks: tasks.value }, {
          header: () => 'タスク一覧',
          item: ({ task, index }) => `${index + 1}. ${task.title}`,
          default: () => h('small', info.value),
        }),
      ])
  },
}

JSX版

// src/BoardJsx.jsx
import { defineComponent, ref, useTemplateRef, onMounted, withModifiers, withKeys } from 'vue'
import Card from './Card.vue'
import TextField from './TextField.vue'
import { vFocus } from './focus.js'

export default defineComponent({
  directives: { focus: vFocus },   // JSX の v-focus はここから解決される
  setup() {
    const tasks = ref([{ id: 1, title: '見積もり' }])
    const text = ref('')
    const memo = ref('')
    const showHelp = ref(false)
    const inputEl = useTemplateRef('inputEl')
    const info = ref('')

    function add() {
      if (!text.value) return
      tasks.value.push({ id: tasks.value.length + 1, title: text.value })
      text.value = ''
    }
    onMounted(() => { info.value = `ref: ${inputEl.value.tagName}` })

    return () => (
      <div class="board">
        <form onSubmit={withModifiers(add, ['prevent'])}>
          <input
            ref="inputEl"
            v-model={text.value}
            v-focus
            class="new"
            onKeyup={withKeys(() => { text.value = '' }, ['esc'])}
          />
          <button type="submit">追加</button>
        </form>
        <TextField v-model={memo.value} />
        <p class="memo">メモ: {memo.value}</p>
        <button class="help-btn" onClick={() => { showHelp.value = !showHelp.value }}>ヘルプ</button>
        <p v-show={showHelp.value} class="help">Enterで追加できます</p>
        {tasks.value.length === 0
          ? <p class="empty">タスクはありません</p>
          : <p class="count">{tasks.value.length}件</p>}
        <Card tasks={tasks.value}>
          {{
            header: () => 'タスク一覧',
            item: ({ task, index }) => `${index + 1}. ${task.title}`,
            default: () => <small>{info.value}</small>,
          }}
        </Card>
      </div>
    )
  },
})

3つを並べてマウントする

// src/main.js
import { createApp } from 'vue'
import BoardTemplate from './BoardTemplate.vue'
import BoardH from './BoardH.js'
import BoardJsx from './BoardJsx.jsx'

createApp(BoardTemplate).mount('#t')
createApp(BoardH).mount('#h')
createApp(BoardJsx).mount('#j')

index.htmlのbodyには、idがt・h・jの3つのdivと、/src/main.js を読み込むtype="module"のscriptタグを置きます。

実行結果:HTMLと操作後の状態を比べる

3つの領域のinnerHTMLを取り出して比べました。その後、それぞれで「発注」と入力してEnter、別の文字を入力してEsc、メモ欄に入力、ヘルプボタンを押す、という同じ操作をしています。

[初期表示] template===h: false  template===jsx: true
 T: <div class="board"><form><input class="new" data-focused="yes"><button type="submit">追加</button></form>…
 H: <div class="board"><form><input class="new" value="" data-focused="yes"><button type="submit">追加</button></form>…
[操作後] template===h: false  template===jsx: true
 T: <div class="board"><form><input class="new" data-focused="yes"><button type="submit">追加</button></form><input class="field" value="午後に確認"><p class="memo">メモ: 午後に確認</p><button class="help-btn">ヘルプ</button><p class="help" style="">Enterで追加できます</p><p class="count">2件</p><section class="card"><header>タスク一覧</header><ul><li>1. 見積もり</li><li>2. 発注</li></ul><small>ref: INPUT</small></section></div>
new の値: "","",""
  • テンプレート版とJSX版は、HTMLが完全に一致しました。
  • h関数版との違いは、inputの value="" 属性だけです。 h関数版はvalueをpropsとして渡しているため、要素を作るときに属性としても出力されます。入力・追加・Escでのクリア・v-showの切り替え・スロットの表示・テンプレート参照(ref: INPUT)は、3つとも同じ結果でした。
  • Enterでフォームを送信してもURLは変わらず、withModifiersのpreventが効いていることも確かめられました。

対応表の補足:つまずきやすい4点

v-modelは「値を渡す」と「更新を受け取る」の2つに分かれる

テンプレートのv-modelは、h関数では2つのpropsになります。

対象 値を渡す 更新を受け取る
input要素 value: text.value onInput: (e) => { text.value = e.target.value }
コンポーネント modelValue: memo.value 'onUpdate:modelValue': (v) => { memo.value = v }

input要素で修飾子(trimなど)も含めてテンプレートと同じ動きにしたい場合は、Vueが内部で使っているvModelTextディレクティブをwithDirectivesで付ける方法もあります。

// src/VModelText.js
import { h, ref, withDirectives, vModelText } from 'vue'

export default {
  setup() {
    const text = ref('')
    return () =>
      h('div', [
        withDirectives(
          h('input', { class: 'vm', 'onUpdate:modelValue': (v) => { text.value = v } }),
          [[vModelText, text.value, '', { trim: true }]],
        ),
        h('p', `入力: [${text.value}]`),
      ])
  },
}

前後に空白を付けて「 資料 」と入力した結果です。trimが効き、value属性も出力されていません。

<div><input class="vm"><p>入力: [資料]</p></div>

JSXでは v-model={text.value} と書けます。.value を付けたrefを渡す点がテンプレートとの違いです。

スロットは「関数を持つオブジェクト」で渡す

h関数の第3引数にオブジェクトを渡すと、キーがスロット名、値がスロットの中身を返す関数になります。スコープ付きスロットの引数は、その関数の引数として受け取ります。

h(Card, { tasks: tasks.value }, {
  header: () => 'タスク一覧',
  item: ({ task, index }) => `${index + 1}. ${task.title}`,
  default: () => h('small', info.value),
})

反対に、スロットを受け取る側は、setupの第2引数の slots から関数として呼び出します。関数型コンポーネントの例で後述します。

ディレクティブはwithDirectivesで包む

withDirectives(vnode, [[ディレクティブ, 値, 引数, 修飾子]]) の形で、vnodeにディレクティブを付けます。v-showはvShowをimportして使います。JSXでは v-show={...} と書け、カスタムディレクティブはコンポーネントの directives オプションに登録しておくと v-focus の形で使えました。

テンプレート参照はrefに同じ名前を渡す

Vue 3.5のuseTemplateRefは、h関数でもJSXでも使えます。useTemplateRef('inputEl') と、要素側の ref: 'inputEl'(JSXでは ref="inputEl")の名前をそろえます。3つの版とも、onMountedで要素のtagNameを読み、「ref: INPUT」と表示できました。

ViteでJSXを動かす設定手順

1. プラグインを入れる

npm install vue
npm install -D vite @vitejs/plugin-vue @vitejs/plugin-vue-jsx

2. vite.config.jsに追加する

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [vue(), vueJsx()],
})

.vue ファイルも使う場合は、このように2つのプラグインを並べます。これで .jsx と .tsx のファイルがVueのJSXとして変換されます(後述のusage.tsxも、この設定のままブラウザで表示とクリックを確かめました)。npx vite build の出力は次のとおりです。

✓ 15 modules transformed.
dist/index.html                 0.27 kB │ gzip:  0.19 kB
dist/assets/index-DoXjNQ-5.js  70.90 kB │ gzip: 27.70 kB

3. TypeScriptを使う場合はtsconfig.jsonを設定する

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "jsxImportSource": "vue",
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["ts/*.tsx"]
}

公式ドキュメントには、tsconfig.jsonで "jsx": "preserve" を指定すること、Vue 3.4以降はグローバルのJSX名前空間を暗黙に登録しなくなったため "jsxImportSource": "vue" を指定することが書かれています。jsxImportSource の行を外して tsc を実行すると、次のエラーになりました。

ts/TaskItem.tsx(8,3): error TS7026: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.
ts/usage.tsx(8,7): error TS7026: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.

TS7026が出たら、まずjsxImportSourceを確かめます。 行を戻すと、エラーなしで通りました。

script setupの中でh関数を使う

script setupは、テンプレートと組み合わせて使う書き方です。トップレベルでレンダー関数をreturnしようとすると、コンパイルの段階でエラーになります。

<script setup>
import { h } from 'vue'
return () => h('p', 'hello')
</script>
[vue/compiler-sfc] 'return' outside of function. (3:0)

script setupの中でh関数を使いたいときは、次の2つの形が使えます。

<!-- src/SetupH.vue -->
<script setup>
import { h, ref } from 'vue'

const count = ref(0)

// 1) 関数型コンポーネントを変数として定義し、テンプレートでタグとして使う
const Badge = (props) => h('span', { class: 'badge' }, `${props.label}: ${count.value}`)
Badge.props = ['label']

// 2) vnode を返す関数を component の is に渡す
const renderList = () => h('ul', ['A', 'B'].map((x) => h('li', { key: x }, `${x}-${count.value}`)))
</script>

<template>
  <button class="inc" @click="count++">+1</button>
  <Badge label="件数" />
  <component :is="renderList" />
</template>
[初期]  <button class="inc">+1</button><span class="badge">件数: 0</span><ul><li>A-0</li><li>B-0</li></ul>
[+1後]  <button class="inc">+1</button><span class="badge">件数: 1</span><ul><li>A-1</li><li>B-1</li></ul>

どちらも、script setupのrefを読んでいて、値が変わると再描画されました。画面全体をh関数で書きたい場合は、script setupではなく通常のscriptブロックを使い、テンプレートを書かずにsetupから関数を返します。

<!-- src/SetupReturn.vue -->
<script>
import { h, ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    // template を書かず、setup からレンダー関数を返す
    return () => h('button', { class: 'inc', onClick: () => count.value++ }, `押した回数: ${count.value}`)
  },
}
</script>
[初期]      <button class="inc">押した回数: 0</button>
[クリック]  <button class="inc">押した回数: 1</button>
やりたいこと 書き方
テンプレートの一部だけh関数で作る script setupで関数型コンポーネントを変数にし、タグとして使う
vnodeを返す関数をその場に差し込む <component :is="関数" />
画面全体をh関数で書く 通常のscriptブロックでsetupから関数を返す、または .js / .jsx ファイルにする

関数型コンポーネントの書き方と型付け

関数型コンポーネントは、状態を持たず、propsを受け取ってvnodeを返すだけの関数です。第1引数がprops、第2引数が { slots, emit, attrs } です。

// src/TaskItem.js
import { h } from 'vue'

// 関数型コンポーネント:状態を持たず、props から vnode を返すだけの関数
const TaskItem = (props, { slots, emit }) =>
  h(
    'li',
    { class: { done: props.done }, onClick: () => emit('toggle', props.id) },
    slots.default ? slots.default() : props.title,
  )

TaskItem.props = { id: Number, title: String, done: Boolean }
TaskItem.emits = ['toggle']

export default TaskItem
// src/FcDemo.js
import { h, ref } from 'vue'
import TaskItem from './TaskItem.js'

export default {
  setup() {
    const tasks = ref([
      { id: 1, title: '見積もり', done: false },
      { id: 2, title: '発注', done: true },
    ])
    const toggle = (id) => {
      const t = tasks.value.find((x) => x.id === id)
      t.done = !t.done
    }
    return () =>
      h('ul', tasks.value.map((t) => h(TaskItem, { key: t.id, ...t, onToggle: toggle })))
  },
}
[初期]              <ul><li class="">見積もり</li><li class="done">発注</li></ul>
[1件目をクリック]   <ul><li class="done">見積もり</li><li class="done">発注</li></ul>

TaskItem.props と TaskItem.emits を関数のプロパティとして宣言している点がポイントです。propsを宣言しておくと、id、title、done がpropsとして渡り、onToggle はemitのリスナーとして扱われます。

TypeScriptで型を付ける

FunctionalComponent 型の第1引数にprops、第2引数にイベントの型を渡します。

// ts/TaskItem.tsx
import type { FunctionalComponent } from 'vue'

type Props = { id: number; title: string; done?: boolean }
type Events = { toggle(id: number): void }

// 第1引数が props、第2引数が emits の型
const TaskItem: FunctionalComponent<Props, Events> = (props, { emit, slots }) => (
  <li class={{ done: props.done }} onClick={() => emit('toggle', props.id)}>
    {slots.default ? slots.default() : props.title}
  </li>
)

TaskItem.props = ['id', 'title', 'done']
TaskItem.emits = ['toggle']

export default TaskItem
// ts/usage.tsx
import { defineComponent, ref } from 'vue'
import TaskItem from './TaskItem'

export default defineComponent({
  setup() {
    const done = ref(false)
    return () => (
      <ul>
        <TaskItem id={1} title="見積もり" done={done.value} onToggle={(id) => { done.value = !done.value; console.log(id.toFixed(0)) }} />
      </ul>
    )
  },
})

この2ファイルは tsc --noEmit をエラーなしで通りました。onToggle の引数 id はnumberと推論されています。次に、型に合わない使い方を書いて確かめます。

// ts/bad.tsx
import TaskItem from './TaskItem'

export const a = <TaskItem id="1" title="見積もり" />
export const b = <TaskItem id={2} />
export const c = <TaskItem id={3} title="発注" onToggle={(id: string) => console.log(id)} />
ts/bad.tsx(3,28): error TS2322: Type 'string' is not assignable to type 'number'.
ts/bad.tsx(4,19): error TS2322: Type '{ id: number; }' is not assignable to type 'IntrinsicAttributes & Props & { onToggle?: ((id: number) => any) | undefined; }'.
  Property 'title' is missing in type '{ id: number; }' but required in type 'Props'.
ts/bad.tsx(5,46): error TS2322: Type '(id: string) => void' is not assignable to type '(id: number) => any'.
  Types of parameters 'id' and 'id' are incompatible.
    Type 'number' is not assignable to type 'string'.
行 書いた内容 検出されたこと
a idに文字列を渡した numberに代入できない
b titleを省いた 必須のtitleがない
c onToggleの引数をstringにした イベントの引数の型が合わない

emitの側も同様に確かめられます。宣言していないイベント名を書くと、次のエラーになりました。

// ts/emit.tsx
import type { FunctionalComponent } from 'vue'

type Props = { id: number }
type Events = { toggle(id: number): void }

export const Bad: FunctionalComponent<Props, Events> = (props, { emit }) => (
  <li onClick={() => emit('remove', props.id)}>{props.id}</li>
)
ts/emit.tsx(7,27): error TS2345: Argument of type '"remove"' is not assignable to parameter of type '"toggle"'.

イベントの型(第2引数)から、使う側の onToggle の型まで自動で決まるので、TSXと関数型コンポーネントの組み合わせは、小さな表示用の部品を型つきで量産するのに向いています。

同じvnodeを使い回すと表示が更新されなくなる

公式ドキュメントには、コンポーネントツリーの中のvnodeはすべて一意でなければならないと書かれています。1つのvnodeを複数の場所に置くとどうなるかを確かめました。

// src/Reuse.js
import { h, ref } from 'vue'

// 同じ vnode を3回並べた例
export const Reuse = {
  setup() {
    const n = ref(0)
    return () => {
      const item = h('li', [h('b', '値 '), h('span', String(n.value))])
      return h('ul', { onClick: () => n.value++ }, [item, item, item])
    }
  },
}

// 並べる数だけ vnode を作る例
export const Fixed = {
  setup() {
    const n = ref(0)
    return () => {
      const item = () => h('li', [h('b', '値 '), h('span', String(n.value))])
      return h('ul', { onClick: () => n.value++ }, [item(), item(), item()])
    }
  },
}

// 子が文字列だけの vnode を3回並べた例
export const ReuseText = {
  setup() {
    const n = ref(0)
    return () => {
      const item = h('li', `値 ${n.value}`)
      return h('ul', { onClick: () => n.value++ }, [item, item, item])
    }
  },
}

ulをクリックするたびにnが1増えます。3つの例の結果です。

[Reuse] 初期:        <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li></ul>
[Reuse] 1回クリック: <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>1</span></li></ul>
[Reuse] 2回クリック: <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>2</span></li></ul>

[Fixed] 初期:        <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li></ul>
[Fixed] 1回クリック: <ul><li><b>値 </b><span>1</span></li><li><b>値 </b><span>1</span></li><li><b>値 </b><span>1</span></li></ul>
[Fixed] 2回クリック: <ul><li><b>値 </b><span>2</span></li><li><b>値 </b><span>2</span></li><li><b>値 </b><span>2</span></li></ul>

[ReuseText] 初期:        <ul><li>値 0</li><li>値 0</li><li>値 0</li></ul>
[ReuseText] 1回クリック: <ul><li>値 1</li><li>値 1</li><li>値 1</li></ul>
[ReuseText] 2回クリック: <ul><li>値 2</li><li>値 2</li><li>値 2</li></ul>
例 最初の表示 更新後
Reuse(子を持つvnodeを3回並べた) 3行とも表示される 最後の1行だけが更新され、前の2行は0のまま
Fixed(関数で毎回作る) 3行とも表示される 3行とも更新される
ReuseText(子が文字列だけのvnodeを3回並べた) 3行とも表示される 3行とも更新された

最初の表示は正しく見え、コンソールに警告も出ませんでした。そのため、値が変わったときに初めて気づく種類の不具合になります。子が文字列だけの単純な形では更新されましたが、子に要素を持たせた時点で表示がずれています。どの形なら動くかを覚えるより、並べる数だけvnodeを作ると決めておくのが確実です。

JSXでも同じ結果でした。JSXの式を変数itemに入れて3回並べると、クリック後に更新されたのは最後の1行だけです。

const item = <li><b>値 </b><span>{n.value}</span></li>
return <ul onClick={() => n.value++}>{item}{item}{item}</ul>
[クリック後] <ul><li><b>値 </b><span>0</span></li><li><b>値 </b><span>0</span></li><li><b>値 </b><span>1</span></li></ul>

FixedのようにJSXでも関数にして、並べる数だけ呼び出します。

よくあるつまずきと直し方

症状 原因 直し方
画面に [object Object] と出る .value を付けていない h関数・JSXではrefを .value で読む
値を変えても再描画されない setupの本体でvnodeを作り、それを返している return () => h(...) のように関数を返し、その中でrefを読む
同じ部品を並べたら一部だけ更新されない 同じvnodeを使い回している 関数にして毎回作る
'return' outside of function script setupのトップレベルでreturnしている 通常のscriptブロックでsetupから返す
TS7026(JSX.IntrinsicElementsがない) jsxImportSourceが未設定 tsconfig.jsonに "jsxImportSource": "vue"
「Non-function value encountered for default slot」の警告 コンポーネントの子にvnodeを直接渡している { default: () => ... } のように関数で渡す
h関数版だけinputに value="" が付く valueをpropsで渡している 動作は同じ。そろえたい場合はvModelTextを使う

1行目と2行目は、次のコードで確かめました。どちらもpをクリックするとcountが1増えます。

// .value を付けていない例
export const NoValue = {
  setup() {
    const count = ref(0)
    return () => h('p', { onClick: () => count.value++ }, `件数: ${count}`)
  },
}

// setup の本体で vnode を作った例
export const StaticVnode = {
  setup() {
    const count = ref(0)
    const vnode = h('p', { onClick: () => count.value++ }, `件数: ${count.value}`)
    return () => vnode
  },
}
[NoValue] 初期:           <p>件数: [object Object]</p>
[StaticVnode] 初期:       <p>件数: 0</p>
[StaticVnode] クリック後: <p>件数: 0</p>

setupは1回しか実行されないため、そこで作ったvnodeは作り直されません。返した関数の中でh関数を呼び、その中でrefを読む形にすると、値の変更に合わせて再描画されます。今回のBoardH.jsやSetupReturn.vueは、すべてこの形で書いています。

よくある質問

Q. h関数とrender関数は何が違いますか? A. h関数はvnodeを1つ作る関数、render関数(レンダー関数)は「そのコンポーネントが何を表示するか」をvnodeで返す関数です。レンダー関数の中でh関数を何度も呼び、画面を組み立てます。setupから関数を返すと、それがレンダー関数として使われます。

Q. テンプレートとh関数は、どちらを使うのがよいですか? A. 通常の画面はテンプレートが向いています。タグ名や構造をデータから組み立てたいとき、スロットの中身を加工したいとき、型つきの小さな部品をTSXで書きたいときに、h関数やJSXを選びます。今回の3つの版は、同じ操作に対して同じ結果になりました。

Q. VueのJSXはReactのJSXと同じですか? A. 見た目は近いですが、変換先がVueのvnodeです。今回動かしたコードでは、属性名は class のまま書き、v-model や v-show をJSXの属性として使い、スロットは関数を持つオブジェクトを子として渡しています。tsconfig.jsonでは "jsxImportSource": "vue" を指定します。

Q. script setupでレンダー関数は使えますか? A. トップレベルでreturnすることはできませんが、関数型コンポーネントを変数として定義してテンプレートでタグとして使う方法と、vnodeを返す関数を component の is に渡す方法が使えました。画面全体をh関数で書くなら、通常のscriptブロックでsetupから関数を返します。

まとめ

  • h関数はvnodeを作る関数、render関数はvnodeを返す関数。 h(タグ, props, 子) の形で呼ぶ
  • v-ifは三項演算子、v-forはmap、v-onは on で始まるprops、v-modelは値と更新イベントの2つに置き換える
  • スロットは関数を持つオブジェクト、ディレクティブはwithDirectives、修飾子はwithModifiersとwithKeysで書く
  • テンプレート・h関数・JSXの3つの版は、同じ操作で同じ結果になった。 違いはh関数版のinputに付く value="" 属性だけ
  • JSXは@vitejs/plugin-vue-jsxを足し、TypeScriptでは jsx: preserve と jsxImportSource: vue を設定する
  • script setupでは、関数型コンポーネントを変数にするか、component の is に関数を渡す
  • 関数型コンポーネントは FunctionalComponent 型でpropsとイベントに型が付く
  • 同じvnodeは使い回さず、関数にして並べる数だけ作る

参考・出典

  • Vue.js 公式ドキュメント「レンダー関数と JSX」(https://ja.vuejs.org/guide/extras/render-function)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vue h関数とrender関数の使い方|テンプレート対応表とJSX」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?