Svelte 5 Runesで書く“HTML Native Components”実践ガイド

# 概要
Svelte 5に[Runes](https://svelte.dev/blog/runes)が導入されたことで、これまでよりも直感的かつ柔軟にリアクティブなUIコンポーネントを実装できるようになりました。

本記事では、Runesの記法で“HTML Native Components”を実装する実践的なテクニックを紹介します。

# $propsについて
Componentを実装する際にはRunes APIの[$props](https://svelte.dev/docs/svelte/$props)をキャッチアップする必要があるため、まずはこちらをButtonコンポーネントを例に、Runes以前の記述と比較してみましょう。

## Svelte 4までのProps受け取り例
```Button.svelte
<script lang="ts">
  export let disabled = false
  export let label: string
</script>

<button {disabled}>{label}</button>
```

## Svelte 5 RunesでのProps受け取り例
```Button.svelte
<script lang="ts">
  let { disabled = false, label }: { disabled: boolean; label: string } =
    $props()
</script>

<button {disabled}>{label}</button>
```

`export let`が非推奨となり、`$props()`による分割代入が推奨スタイルになりました。
また、より実践的には型定義部分を以下のように切り離せます。

```Button.svelte
<script lang="ts">
  type Props = {
    disabled?: boolean
    label: string
  }

  let { disabled = false, label }: Props = $props()
</script>

<button {disabled}>{label}</button>
```

## Rest propsについて
より実践的なテクニックとして、`'svelte/elements'`で型定義されている各種HTMLElement / Attributesの型定義と[Rest props](https://svelte.dev/docs/svelte/$props#Rest-props)を組み合わせることでカスタムコンポーネントを“HTML Native Components”として実装することができます。

```Button.svelte
<script lang="ts">
  import type { HTMLButtonAttributes } from 'svelte/elements'

  type Props = {
    label: string
  } & HTMLButtonAttributes

  let { label, ...restProps }: Props = $props()
</script>

<button {...restProps}>{label}</button>
```

スプレッド構文を使った分割代入(`{...restProps}`)をすることで独自定義の`label`を除いた`HTMLButtonAttributes`が`restProps`というオブジェクトにまとめることができるため、buttonタグの属性やハンドラをPropsを介してスマートに設定することができ、親コンポーネントからはbuttonタグのように、つまり“HTML Native Components”のように扱うことができます。

![disabled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1689782/4f6f765f-1e74-4f4e-8645-512179721b1b.png)
![onclick.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1689782/ba7dda4b-6cae-460f-8b52-1f613a577dd8.png)
*disabledやonclickの型情報がHTMLButtonAttributesを介して認識されていることが確認できる*

## childrenとSnippetについて
コンポーネントをより柔軟に設計するには`label`のような文字列ではなく、`children`を使うことで親コンポーネントから任意の要素やHTML、他のコンポーネントを渡せるようにするのが効果的です。

```Button.svelte
<script lang="ts">
  import type { HTMLButtonAttributes } from 'svelte/elements'

  type Props = {} & HTMLButtonAttributes

  let { children, ...restProps }: Props = $props()
</script>

<button {...restProps}>
  <!-- undefinedを考慮し?.()とする必要がある -->
  {@render children?.()}

  <!-- あるいはifで制御 -->
  {#if children}
    {@render children()}
  {/if}
</button>
```

`children`はSvelte 5に導入された新しいpropsで型としては`Snippet<[]> | undefined`として扱われ、従来の`<slot />`よりも型安全かつ柔軟に扱うことができるようになりました。

例えば、以下のように明示的な定義にすると`children`を必須とすることができ、親コンポーネント側で内包要素を書き忘れている場合にエラーとすることができます。

```Button.svelte
<script lang="ts">
  import type { Snippet } from 'svelte'
  import type { HTMLButtonAttributes } from 'svelte/elements'

  type Props = {
    children: Snippet
  } & HTMLButtonAttributes

  let { children, ...restProps }: Props = $props()
</script>

<button {...restProps}>
  <!-- undefinedの考慮が不要になる -->
  {@render children()}
</button>
```

![button.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1689782/2a9b925d-5a94-4436-aeb7-5e9d87861c92.png)
*内包要素の書き忘れによるエラー*

<br/>
さて、ここからはこれまでに紹介したPropsのテクニックを組み合わせて、より実践的で汎用的な“HTML Native Components”の実装例を紹介していきたいと思います。

# CommonButton
まずは汎用ボタンコンポーネントから。  
ユースケースに応じて様々なカスタマイズ要件があると思いますが、汎用的な例としてボタンのバリエーションを`variant`という`props`でサポート。

```CommonButton.svelte
<script lang="ts">
  import type { Snippet } from 'svelte'
  import type { HTMLButtonAttributes } from 'svelte/elements'

  type Props = {
    variant?: 'default' | 'primary' | 'secondary'
    children: Snippet
  } & HTMLButtonAttributes

  let {
    variant = 'default',
    class: className = '',
    children,
    ...restProps
  }: Props = $props()
</script>

<button class={[className, 'btn', `btn-${variant}`]} {...restProps}>
  {@render children()}
</button>

<style lang="scss">
  .btn {
    /* Base styles */

    &.btn-primary {
      /* Primary button styles */
    }

    &.btn-secondary {
      /* Secondary button styles */
    }
  }
</style>
```

`children`や`restProps`についてはすでに紹介の通り。
`class`属性については、予約語と衝突するため、分割代入時に`class: className = ''`のようにリネームして扱う方法がSvelteの公式ドキュメントでも紹介されています。

# CommonInput
続いて汎用入力コンポーネント。

```CommonInput.svelte
<script lang="ts">
  import type { HTMLInputAttributes } from 'svelte/elements'

  type Props = {
    invalid?: boolean
  } & HTMLInputAttributes

  let {
    type = 'text',
    invalid = false,
    class: className = '',
    value = $bindable(''),
    ...restProps
  }: Props = $props()
</script>

<input
  {type}
  class={[className, 'input', { invalid }]}
  {...restProps}
  bind:value
/>

<style lang="scss">
  .input {
    /* Base styles */

    &.invalid {
      /* Invalid styles */
    }
  }
</style>
```

`value`については、Svelte 5 Runesの[$bindable](https://svelte.dev/docs/svelte/$bindable)を使うことで、親コンポーネントから`bind:value`で双方向バインディングできるように実装できます。
```App.svelte
<script lang="ts">
  import CommonInput from '$lib/components/common/CommonInput.svelte'

  let value = ''
</script>

<CommonInput bind:value />
```

また、`<input type="checkbox">`や`<input type="radio">`については後述のような専用コンポーネントを実装してそちらを利用するようにしたいというケースもよくあるかと。

その場合には`'svelte/elements'`で型定義されている`HTMLInputTypeAttribute`とtypescriptの[Extract](https://www.typescriptlang.org/docs/handbook/utility-types.html#extracttype-union)を組み合わせることで汎用入力コンポーネントが受け入れるtype属性を制限することができます。

```CommonInput.svelte
<script lang="ts">
  import type {
    HTMLInputAttributes,
    HTMLInputTypeAttribute
  } from 'svelte/elements'

  type AllowedInputType = Extract<
    HTMLInputTypeAttribute,
    | 'date'
    | 'datetime-local'
    | 'email'
    | 'file'
    | 'month'
    | 'number'
    | 'password'
    | 'search'
    | 'tel'
    | 'text'
    | 'time'
    | 'url'
    | 'week'
  >

  type Props = {
    type?: AllowedInputType
    invalid?: boolean
  } & HTMLInputAttributes

  let {
    type = 'text',
    invalid = false,
    class: className = '',
    value = $bindable(''),
    ...restProps
  }: Props = $props()
</script>

<input
  {type}
  class={[className, 'input', { invalid }]}
  {...restProps}
  bind:value
/>

<style lang="scss">
  .input {
    /* Base styles */

    &.invalid {
      /* Invalid styles */
    }
  }
</style>
```

![extract-type.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1689782/992e266f-1b09-49ad-828c-d9d4212b1170.png)
*親コンポーネントは`type="radio"`の指定はできない*

# CommonCheckbox
続いて汎用チェックボックスコンポーネント。

```CommonCheckbox.svelte
<script lang="ts">
  import type { HTMLInputAttributes } from 'svelte/elements'

  type Props = {
    invalid?: boolean
  } & Omit<HTMLInputAttributes, 'type'>

  let {
    invalid = false,
    class: className = '',
    checked = $bindable(false),
    children,
    ...restProps
  }: Props = $props()
</script>

<label class={className}>
  <input type="checkbox" {...restProps} bind:checked />
  <span class={['checkbox', { invalid }]}></span>
  {@render children?.()}
</label>

<style lang="scss">
  label {
    cursor: pointer;
    display: flex;
    justify-content: center;
    align-items: center;
  }

  input[type='checkbox'] {
    display: none;

    &:checked + .checkbox {
      /* Checked styles */
    }
  }

  .checkbox {
    /* Base styles */

    &.invalid {
      /* Invalid styles */
    }
  }
</style>
```

先ほどの汎用入力コンポーネントでは`type="checkbox"`を拒否するように実装しましたが、こちらはHTMLInputAttributesを受け入れつつもtype属性の指定は受け付けたくないので、`Omit<HTMLInputAttributes, 'type'>`とすることでHTMLInputAttributesからtypeを除外。

また、Svelteの仕様として`input type="checkbox"`に`bind:value`を指定した場合には、`bind:checked`に置き換えるようにエラーが出るので`$bindable`は`checked`に適用。

タグ構成としては、labelタグで囲むことによって親コンポーネントから渡した`children`を含むカスタムコンポーネント全体のクリックイベントでinputタグに対する操作ができるように調整し、スタイルはよくある標準のinputタグを非表示にしてspanタグでカスタムスタイルを実装できるようにしています。

# CommonTextarea
最後に汎用テキストエリアコンポーネント。  
せっかくなので本記事でまだ紹介できていない、`use:`と`$effect`を組み合わせて入力行に応じて高さが可変となるようにします。

```CommonTextarea.svelte
<script lang="ts">
  import { flexTextarea } from '$lib/actions'

  import type { HTMLTextareaAttributes } from 'svelte/elements'

  type Props = {
    invalid?: boolean
  } & HTMLTextareaAttributes

  let {
    invalid = false,
    class: className = '',
    value = $bindable(''),
    ...restProps
  }: Props = $props()
</script>

<textarea
  class={[className, 'textarea', { invalid }]}
  {...restProps}
  use:flexTextarea
  bind:value
></textarea>

<style lang="scss">
  .textarea {
    /* Base styles */

    &.invalid {
      /* Invalid styles */
    }
  }
</style>
```

```flex-textarea.svelte.ts
import type { Action } from 'svelte/action'

export const flexTextarea: Action<HTMLTextAreaElement> = (node) => {
  $effect(() => {
    const adjustHeight = () => {
      node.style.height = '0px'
      node.style.height = node.scrollHeight + 'px'
    }

    adjustHeight()
    node.addEventListener('input', adjustHeight)

    return () => {
      node.removeEventListener('input', adjustHeight)
    }
  })
}
```

[use:](https://svelte.dev/docs/svelte/use)は、**要素にカスタムアクション（Action）を適用するための仕組み**です。  
Reactでいう「カスタムフック」や、Vueの「カスタムディレクティブ」に近い役割を持ち、DOM要素に対して直接的な振る舞いを追加したい場合などに利用します。

[$effect](https://svelte.dev/docs/svelte/$effect)はSvelte 5 Runesの一つで、リアクティブな副作用（エフェクト）を定義するためのAPIです。  
`$effect`内で記述した処理は、依存する値が変化したタイミングや、要素のマウント時に自動的に実行されます。
その性質上、`$effect`はSvelte 4までの`onMount`や`afterUpdate`のようなライフサイクルフックの役割も担いますが、  
Runes構文では依存関係が明示的になり、より直感的かつ安全に副作用を管理できるのが特徴です。

今回の例ではテキストエリアに入力がある度に、要素の`scrollHeight`を`style.height`に代入することで、入力行に応じて高さが可変となるように実装しています。

また、再利用性を考慮して別ファイル(`flex-textarea.svelte.ts`)に切り出していますが、Svelte 5 Runes(`$effect`など)を使用するにはファイル名に制限があるので注意してください。
※`.svelte`あるいは`.svelte.ts` or `.svelte.js`

https://svelte.dev/docs/svelte/svelte-js-files

# おわりに
本記事で紹介した汎用コンポーネントは、もちろんSvelte 4以前でも同じような実装はできます。  
しかし、Svelte 5 Runesを使うことでより直感的かつ柔軟にリアクティブなUIコンポーネントを実装できるようになったかと思います。

Svelteが界隈で評価された大きな理由として、独自構文ではなくVanilla JSのような構文でリアクティブな機能を実装できるという点があったため、一部ではRunesの導入を疑問視する声もあり、公式でもこの点についてはSvelte らしくない(un-Svelte-like)という表現で言及されています。

https://svelte.dev/blog/runes

ただ、個人的には、上記記事中の主張のようにプロジェクトの規模が大きく複雑になるにつれて避けられないリアクティブな箇所とそうでない箇所の見通しという観点では、非常に良い変更なのではないかと思います。  
何よりも書いていて楽しいので、是非、**Svelte 5 Runes**をお試しください。

# 参考
https://svelte.dev/blog/runes

https://svelte.dev/docs/svelte/$effect

https://svelte.dev/docs/svelte/$props

https://svelte.dev/docs/svelte/$bindable

https://svelte.dev/docs/svelte/use

https://www.typescriptlang.org/docs/handbook/utility-types.html#extracttype-union