【Vanilla JS】フレームワークなしで病院トリアージダッシュボードを作った話
なぜ Vanilla JS で作るのか
最近のフロントエンド開発というと、React か Vue か Next.js、みたいな話になりがちだ。私自身もそれらを日常的に使っている。だが今回は意図的に「フレームワークなし」を選択した。
きっかけは、知人の医療系スタートアップから「病院受付のトリアージと待合室管理を一画面に収めたダッシュボードが欲しい」という相談を受けたことだ。要件を聞くと、「ネット環境が不安定な場所でも動くこと」「端末のスペックが古い可能性がある」「保守担当が JS に詳しくない」という制約が重なった。
バンドルサイズもゼロ、ビルドツールもなし、CDN 一本で動く——そこに Vanilla JS の出番があった。
プロジェクト概要
DoCare という名前をつけたこのダッシュボードは、大きく3つの機能を持っている。
単一の HTML ファイルで完結するよう設計し、外部依存はゼロにした。
CSS Nesting で構造を整理する
まずスタイリングから手をつけた。CSS Nesting はブラウザネイティブで使えるようになってきており、プリプロセッサ不要でセレクタの入れ子が書ける。
.triage-panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
& .triage-card {
border-radius: 12px;
padding: 1.5rem;
transition: transform 0.2s ease;
&:hover {
transform: translateY(-2px);
}
&.priority-red {
background: #fee2e2;
border-left: 4px solid #ef4444;
}
&.priority-yellow {
background: #fef9c3;
border-left: 4px solid #eab308;
}
&.priority-green {
background: #dcfce7;
border-left: 4px solid #22c55e;
}
}
}
SCSS に慣れていると「ネイティブで動くのか?」と疑いたくなるが、Chrome 112 以降と Safari 16.5 以降はサポート済みだ。このプロジェクトのターゲット端末が Chrome 系だったので問題なかった。
状態管理を自前で実装する
React なら useState、Vue なら ref を使うところだが、Vanilla JS では状態管理も自分で書く。シンプルなオブザーバーパターンで十分だと判断した。
var store = (function () {
var state = {
patients: [],
waitingRoom: [],
announced: {},
};
var listeners = [];
function getState() {
return JSON.parse(JSON.stringify(state));
}
function setState(newPartial) {
Object.assign(state, newPartial);
listeners.forEach(function (fn) {
fn(getState());
});
}
function subscribe(fn) {
listeners.push(fn);
}
return { getState: getState, setState: setState, subscribe: subscribe };
})();
IIFE でモジュールを囲み、グローバルスコープを汚染しない。setState を呼ぶたびに全リスナーに通知が走り、UI が更新される。Redux の最小版といったところだ。
患者登録とトリアージ分類
患者登録フォームの送信イベントをキャプチャし、状態を更新する。
document.getElementById('patient-form').addEventListener('submit', function (e) {
e.preventDefault();
var name = document.getElementById('patient-name').value.trim();
var priority = document.querySelector('input[name="priority"]:checked').value;
if (!name) return;
var patient = {
id: Date.now().toString(),
name: name,
priority: priority,
arrivedAt: new Date().toLocaleTimeString('ja-JP'),
status: 'waiting',
};
var current = store.getState();
store.setState({
patients: current.patients.concat(patient),
});
e.target.reset();
});
状態が変わると、登録しておいたレンダリング関数が呼び出され、トリアージカードを再描画する。DOM 操作はinnerHTMLで行い、XSS 対策として患者名はtextContentでセットする。
Web Speech Synthesis API で音声呼び出し
このプロジェクトで一番面白かったのが、Speech Synthesis API を使った音声アナウンスだ。
function announcePatient(patientName) {
if (!window.speechSynthesis) {
console.warn('このブラウザは音声合成に対応していません');
return;
}
var utterance = new SpeechSynthesisUtterance(
patientName + ' 様、診察室にお越しください'
);
utterance.lang = 'ja-JP';
utterance.rate = 0.9;
utterance.pitch = 1.0;
utterance.volume = 1.0;
// 日本語音声を優先して選択
var voices = window.speechSynthesis.getVoices();
var japaneseVoice = voices.find(function (v) {
return v.lang === 'ja-JP';
});
if (japaneseVoice) {
utterance.voice = japaneseVoice;
}
window.speechSynthesis.speak(utterance);
}
getVoices() は非同期で読み込まれるため、初回呼び出し時にリストが空になることがある。そのため speechSynthesis.onvoiceschanged イベントで取得タイミングを調整した。
window.speechSynthesis.onvoiceschanged = function () {
// 音声リストが準備できた
availableVoices = window.speechSynthesis.getVoices();
};
実際にデモを見せたとき、「本当に喋るんですか!」と受付スタッフの方が驚いていたのが印象に残っている。追加ライブラリゼロ、ブラウザの標準機能だけでこれができる。
待合室ボードのリアルタイム更新
待合室ボードは登録から呼び出しまでの流れを管理する。
store.subscribe(function (state) {
renderTriageCards(state.patients);
renderWaitingBoard(state.waitingRoom);
});
function renderWaitingBoard(waitingRoom) {
var board = document.getElementById('waiting-board');
board.innerHTML = '';
if (waitingRoom.length === 0) {
board.innerHTML = '待機中の患者はいません
';
return;
}
waitingRoom.forEach(function (patient, index) {
var row = document.createElement('tr');
row.className = 'waiting-row' + (patient.called ? ' is-called' : '');
var numCell = document.createElement('td');
numCell.textContent = index + 1;
var nameCell = document.createElement('td');
nameCell.textContent = patient.name;
var actionCell = document.createElement('td');
var callBtn = document.createElement('button');
callBtn.textContent = patient.called ? '呼出済' : '呼び出す';
callBtn.disabled = patient.called;
callBtn.addEventListener('click', function () {
callPatient(patient.id);
});
actionCell.appendChild(callBtn);
row.appendChild(numCell);
row.appendChild(nameCell);
row.appendChild(actionCell);
board.appendChild(row);
});
}
作ってみて気づいた Vanilla JS の強さ
このプロジェクトを通じて、Vanilla JS に対する見方が少し変わった。
バンドルサイズの議論が消える: 依存ゼロなので、「このライブラリを入れたら何 KB 増えるか」という計算が不要だ。初期ロードは HTML 一枚、CSS 一枚、JS 一枚だけ。
デバッグが素直: フレームワークの抽象レイヤーがないため、ブラウザの DevTools でそのままコードが読める。保守担当が JS 初心者でも、エラーのスタックトレースがわかりやすい。
ブラウザ API との距離が近い: Speech Synthesis のような Web API を使うとき、フレームワーク側の抽象化を挟まずに直接扱える。ドキュメントを MDN で引けば実装できる。
一方で、状態管理や DOM 更新を手書きするコストは確かにある。コンポーネントが増えると管理が辛くなってくる。「このくらいの規模まではフレームワーク不要」というラインを自分で引けるようになったのが、今回の収穫だった。
まとめ
DoCare ダッシュボードの開発で学んだことをまとめる。
フレームワークを使うかどうかは目的に応じて選ぶ話で、Vanilla JS はまだまだ現役だと実感した。