概要
Svelte 5にRunesが導入されたことで、これまでよりも直感的かつ柔軟にリアクティブなUIコンポーネントを実装できるようになりました。
本記事では、Runesの記法で“HTML Native Components”を実装する実践的なテクニックを紹介します。
$propsについて
Componentを実装する際にはRunes APIの$propsをキャッチアップする必要があるため、まずはこちらをButtonコンポーネントを例に、Runes以前の記述と比較してみましょう。
Svelte 4までのProps受け取り例
<script lang="ts">
export let disabled = false
export let label: string
</script>
<button {disabled}>{label}</button>
Svelte 5 RunesでのProps受け取り例
<script lang="ts">
let { disabled = false, label }: { disabled: boolean; label: string } =
$props()
</script>
<button {disabled}>{label}</button>
export letが非推奨となり、$props()による分割代入が推奨スタイルになりました。
また、より実践的には型定義部分を以下のように切り離せます。
<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を組み合わせることでカスタムコンポーネントを“HTML Native Components”として実装することができます。
<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やonclickの型情報がHTMLButtonAttributesを介して認識されていることが確認できる
childrenとSnippetについて
コンポーネントをより柔軟に設計するにはlabelのような文字列ではなく、childrenを使うことで親コンポーネントから任意の要素やHTML、他のコンポーネントを渡せるようにするのが効果的です。
<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を必須とすることができ、親コンポーネント側で内包要素を書き忘れている場合にエラーとすることができます。
<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>
さて、ここからはこれまでに紹介したPropsのテクニックを組み合わせて、より実践的で汎用的な“HTML Native Components”の実装例を紹介していきたいと思います。
CommonButton
まずは汎用ボタンコンポーネントから。
ユースケースに応じて様々なカスタマイズ要件があると思いますが、汎用的な例としてボタンのバリエーションをvariantというpropsでサポート。
<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
続いて汎用入力コンポーネント。
<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を使うことで、親コンポーネントからbind:valueで双方向バインディングできるように実装できます。
<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を組み合わせることで汎用入力コンポーネントが受け入れるtype属性を制限することができます。
<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>
CommonCheckbox
続いて汎用チェックボックスコンポーネント。
<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を組み合わせて入力行に応じて高さが可変となるようにします。
<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>
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:は、要素にカスタムアクション(Action)を適用するための仕組みです。
Reactでいう「カスタムフック」や、Vueの「カスタムディレクティブ」に近い役割を持ち、DOM要素に対して直接的な振る舞いを追加したい場合などに利用します。
$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
おわりに
本記事で紹介した汎用コンポーネントは、もちろんSvelte 4以前でも同じような実装はできます。
しかし、Svelte 5 Runesを使うことでより直感的かつ柔軟にリアクティブなUIコンポーネントを実装できるようになったかと思います。
Svelteが界隈で評価された大きな理由として、独自構文ではなくVanilla JSのような構文でリアクティブな機能を実装できるという点があったため、一部ではRunesの導入を疑問視する声もあり、公式でもこの点についてはSvelte らしくない(un-Svelte-like)という表現で言及されています。
ただ、個人的には、上記記事中の主張のようにプロジェクトの規模が大きく複雑になるにつれて避けられないリアクティブな箇所とそうでない箇所の見通しという観点では、非常に良い変更なのではないかと思います。
何よりも書いていて楽しいので、是非、Svelte 5 Runesをお試しください。
参考

