はじめに
ReactやVueを使っていると、ふと「このアプリ、本当にここまでの仕組みが必要だったのか」と思う瞬間はないでしょうか。ビルド設定、状態管理ライブラリ、ルーティング、副作用の扱い方……覚えることは年々増えていく一方です。
Hyperappは、その真逆を行くフレームワークです。State・View・Actionsという3つの概念だけでUIを組み立て、非同期処理やイベント購読もEffectsとSubscriptionsという仕組みに統一します。パッケージサイズはminify+gzip後で約1kB。それでいてVirtual DOMによる差分更新も備えており、「小さいけれど機能を削っただけ」ではない設計になっています。
この記事では、そんなHyperappの特徴からインストール、Actions・Effects・Subscriptionsを使った実践的な書き方までを順に見ていきます。
とはいえ、読むより触った方が早いと思います。カウンターの動作を実際に触って確かめられるサンプルを用意したので、先に挙動を見たい方はこちらからどうぞ。
Hyperappとは
Hyperappは、Jorge Bucaranさんが開発している「ハイパーテキストアプリケーション構築のための超軽量フレームワーク」です。ElmアーキテクチャやReduxに近い一方向データフローを採用しつつ、Reactのようなクラスやフックを使わず、すべてをプレーンな関数として書けるのが特徴です。
主な特徴
- 極小サイズ - minify+gzipで約1kB。ライブラリを1つ追加するコストがほぼ気にならない
- 少ない概念で構成 - State・View・Actions・Effects・Subscriptionsだけで完結し、覚えることが少ない
- 宣言的なVirtual DOM -
h()関数でVirtual DOMを構築し、差分検出は内部の最適化されたdiffアルゴリズムに任せられる - 副作用の一元管理 -
fetchやタイマー、イベント購読といった副作用をEffects・Subscriptionsとして状態遷移と同じ流れで扱える
インストール
npmからインストールできます。
npm install hyperapp
CDN経由でそのまま読み込むことも可能です。
<script type="module">
import { h, text, app } from "https://unpkg.com/hyperapp"
</script>
Hyperappのサンプルを動かす
まずは最小構成のカウンターアプリで、Hyperappの基本的な流れをつかんでみましょう。h()でVirtual DOMを組み立て、text()でテキストノードを作り、app()でstate・view・マウント先のnodeを渡して起動する、という流れがHyperappの基本形です。ボタンを押すとstate(数値)が更新され、viewが再計算されて画面が再描画されます。
要点だけを抜き出すと、次のようになります。
import { h, text, app } from 'hyperapp'
const AddOne = (state) => state + 1
const SubOne = (state) => state - 1
app({
init: 0,
view: (state) =>
h('div', {}, [
h('h1', {}, text(state)),
h('button', { onclick: AddOne }, text('+1')),
h('button', { onclick: SubOne }, text('-1')),
]),
node: document.getElementById('app'),
})
実際に動かせるものが下です。「+1」「-1」ボタンを押して、stateが更新されるたびにviewが再描画される様子を確かめてみてください。
AddOneやSubOneのようなActionsは、受け取ったstateから新しいstateを返すだけの純粋関数です。クラスもフックも登場しないぶん、状態遷移のロジックがどこで何をしているか追いやすくなっています。Resetアクションを真似て、stateを任意の値にジャンプさせるボタンを増やしてみると、Actionsの書き方がより掴みやすくなるはずです。
基本的な使い方
Hyperappの基本的な書き方は、大きく3つの要素の組み合わせです。
initで初期状態(state)を決めるviewで、stateからVirtual DOM(h()とtext()の組み合わせ)を作る- イベントハンドラにActions(stateを受け取り新しいstateを返す関数)を割り当てる
import { h, text, app } from 'hyperapp'
const ToggleDone = (state) => ({ ...state, done: !state.done })
app({
init: { done: false },
view: ({ done }) =>
h('div', {}, [
h('p', {}, text(done ? '完了しました' : '未完了です')),
h('button', { onclick: ToggleDone }, text('切り替える')),
]),
node: document.getElementById('app'),
})
stateはオブジェクトでも数値でも構いません。viewは常に「今のstateからどんなDOMになるべきか」を返すだけで、DOM操作そのものはHyperappの内部が引き受けてくれます。
実践的なユースケース
Hyperappには「Actionsで配列やオブジェクトを更新する」「Effectsで非同期処理を扱う」「Subscriptionsでイベントを購読し続ける」という3つの代表的な使い方があります。それぞれ独立したサンプルで見ていきましょう。
Actionsで管理するTodoリスト
単一の数値や真偽値ではなく、配列を持つstateをActionsで更新するパターンです。TodoリストのようにitemsをAddしたりtoggleしたりする処理では、Actionsの中でstate.itemsをスプレッド構文で複製し、新しい配列を作って返すのが基本になります。
const AddTodo = (state) => ({
todos: state.todos.concat({ text: state.value, done: false }),
value: '',
})
const ToggleTodo = (state, index) => ({
...state,
todos: state.todos.map((todo, i) =>
i === index ? { ...todo, done: !todo.done } : todo
),
})
実際に入力してTodoを追加し、チェックボックスで完了/未完了を切り替えられるサンプルです。入力欄にテキストを入れて「追加」を押すとリストに積まれ、各行をクリックすると完了状態がその場で切り替わります。
ToggleTodoが第2引数にindexを受け取っているように、Hyperappのイベントハンドラは(state, payload) => newStateという形のActionを渡すこともできます。配列を書き換えたい要件が増えてきたら、concatやmapで新しい配列を作って返す、というこの型を覚えておくと応用が利きます。
Effectsで非同期処理を扱う
ボタンを押したら一定時間後に自動でメッセージを消す、といった「時間差のある処理」はEffectsの出番です。HyperappのActionsは新しいstateだけでなく、[newState, effect]という形でEffectを1つ以上付け足したタプルを返せます。Effectそのものは[effecter, payload]という配列で、effecterは(dispatch, payload) => voidというシグネチャを持つ関数です。
const delay = (dispatch, props) => {
const id = setTimeout(() => dispatch(props.action), props.duration)
return () => clearTimeout(id)
}
const ShowMessage = (state) => [
{ ...state, message: '保存しました' },
[delay, { duration: 2000, action: HideMessage }],
]
const HideMessage = (state) => ({ ...state, message: '' })
「保存」ボタンを押すとメッセージが表示され、setTimeoutベースのEffectが2秒後に自動でHideMessageアクションをdispatchしてメッセージを消します。
保存ボタンを連打すると、押すたびに新しいsetTimeoutのEffectが発行されタイマーが延長されていく様子も確認できます。delayのdurationを短くしたり、actionを別のアクションに差し替えたりすると、Effectsが「副作用を状態遷移の一部として宣言する」仕組みだと実感しやすくなります。
Subscriptionsでイベントを購読し続ける
一度きりの副作用がEffectsなら、setIntervalやイベントリスナーのように「動いている間ずっと購読し続ける」副作用を扱うのがSubscriptionsです。app()にsubscriptions関数を渡し、[subscriber, payload]の配列を返します。subscriberは(dispatch, payload) => cleanupFnという形で、返り値のクリーンアップ関数が購読解除の役割を持ちます。
const everySecond = (dispatch, props) => {
const id = setInterval(() => dispatch(props.action), 1000)
return () => clearInterval(id)
}
app({
init: { count: 0, running: true },
subscriptions: (state) =>
state.running ? [[everySecond, { action: Tick }]] : [],
// ...
})
「開始/停止」ボタンでSubscriptionsのON/OFFを切り替えられるサンプルです。runningがtrueの間だけeverySecondの購読が有効になり、1秒ごとにカウントが増えます。停止を押すとクリーンアップ関数が呼ばれclearIntervalが実行されるため、カウントアップが止まります。
Subscriptionsはstateを見て購読するかどうかを毎回判定し直す仕組みなので、runningのようなフラグ1つでON/OFFを切り替えられます。1000を200に変えて更新頻度を上げてみたり、ToggleRunningを別条件に差し替えたりすると、「stateの値に応じて購読自体を変化させる」というSubscriptionsらしい挙動がつかめます。
まとめ
Hyperappは、State・View・Actionsという最小限の概念でUIを組み立てつつ、EffectsとSubscriptionsによって非同期処理やイベント購読も同じ一方向データフローの中に収める、約1kBの軽量フレームワークです。Reactのエコシステムほど周辺ツールは多くありませんが、その分「今動いているコードの全体像を頭の中に収めやすい」という強みがあります。
小さなツールやプロトタイプ、あるいは「フレームワークの仕組みそのものを理解したい」という学習目的にも向いています。今回のサンプルを土台に、ルーティングやフォームバリデーションなど、より複雑な状態を持つアプリを組んでみてはいかがでしょうか。