【Vanilla JS】フレームワークなしで病院トリアージダッシュボードを作った話

なぜ Vanilla JS で作るのか

最近のフロントエンド開発というと、React か Vue か Next.js、みたいな話になりがちだ。私自身もそれらを日常的に使っている。だが今回は意図的に「フレームワークなし」を選択した。

きっかけは、知人の医療系スタートアップから「病院受付のトリアージと待合室管理を一画面に収めたダッシュボードが欲しい」という相談を受けたことだ。要件を聞くと、「ネット環境が不安定な場所でも動くこと」「端末のスペックが古い可能性がある」「保守担当が JS に詳しくない」という制約が重なった。

バンドルサイズもゼロ、ビルドツールもなし、CDN 一本で動く——そこに Vanilla JS の出番があった。

プロジェクト概要

DoCare という名前をつけたこのダッシュボードは、大きく3つの機能を持っている。

  • **トリアージパネル**: 患者を緊急度(赤・黄・緑)で分類し、優先順位を管理する
  • **待合室ボード**: 受付済み患者の一覧と呼び出し状態をリアルタイムで表示する
  • **音声アナウンス**: Web Speech Synthesis API を使って患者呼び出しをアナウンスする
  • 単一の 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 ダッシュボードの開発で学んだことをまとめる。

  • CSS Nesting はプリプロセッサなしでも十分整理されたスタイルが書ける
  • シンプルなオブザーバーパターンで状態管理は自前実装できる
  • Web Speech Synthesis API は追加ライブラリゼロで音声合成ができる
  • Vanilla JS は「小〜中規模、ネット環境不安定、保守コスト重視」のケースで強い
  • フレームワークを使うかどうかは目的に応じて選ぶ話で、Vanilla JS はまだまだ現役だと実感した。