📘 はじめに
臨床看護師歴10年以上、現在はWebフロントエンドエンジニアも兼業しています。
看護業務で日常的に行う
- 投薬計算
- 点滴速度
- 電解質補正
といった処理を、型安全かつ保守しやすい形でまとめたWebアプリを開発しました。
デモ:こちらからお試しできます
コード:Github🐈
本記事では、実装の中でも特に重要だった
- アーキテクチャ設計
- 型安全なバリデーション
- 再利用設計
について技術的にお話します。
安全性を担保するためにも、
本アプリの結果のみで判断しないようお願いいたします。
⚙️ 技術スタック
| 分類 | 採用技術 |
|---|---|
| フレームワーク | Next.js(App Router) |
| 言語 | TypeScript(strict) |
| フォーム管理 | React Hook Form v7 |
| バリデーション | Zod v3 |
| スタイリング | Tailwind CSS |
| DnD | @dnd-kit |
| 永続化 | localStorage |
| デプロイ | Vercel |
| PWA | Service Worker |
⚙️ アーキテクチャ概要
9種類の計算機はすべて同一パターンで構築しています。
src/
├── app/
│ ├── _components/
│ ├── [calculator]/
│ │ ├── page.tsx
│ │ ├── Calculator.tsx
│ │ └── schema.ts
├── hooks/
│ └── useCalculator.ts
├── lib/
│ ├── history.ts
│ └── reuse/
└── types/
🧩 1. useCalculator:計算処理の共通化
解決した課題
各計算機で共通して発生する処理:
- useForm初期化
- バリデーション設定
- 履歴復元
- result管理
- スクロール制御
→ カスタムフックに集約
export function useCalculator<TInputs, TResult = string>(
schema: ZodTypeAny,
typeId: string,
) {
const [result, setResult] = useState<TResult | null>(null);
const form = useForm<TInputs>({
resolver: zodResolver(schema),
shouldUnregister: false,
});
useEffect(() => {
const data = getTypedReusePayloadOnce(typeId, schema);
if (data) form.reset(data);
}, []);
return { result, setResult, ...form };
}
メリット
- ボイラープレート削減
- 型安全なresult管理
- 全計算機で統一された挙動
🧠 2. Zodによる型安全バリデーション
空文字問題の対策
export const prep = (val: unknown) =>
val === "" ? undefined : val;
z.preprocess(
prep,
z.coerce.number().min(1)
);
→ "" → undefined に変換し、エラーを正しく発火
クロスフィールド検証
.refine((data) => data.targetHb > data.currentHb, {
message: "目標Hbは現在Hbより高く設定してください",
path: ["targetHb"],
});
動的enum生成
const deviceIds = oxygenDevices.map(d => d.id);
z.enum(deviceIds as [string, ...string[]]);
→ 定数とスキーマの二重管理を防止
🔁 3. 履歴→再利用フロー(型保証あり)
設計
履歴保存
→ localStorage
→ 別ページで読み込み
→ Zodで検証
→ form.reset
ポイント
- 外枠スキーマ(JSON構造)
- 内枠スキーマ(入力データ)
- 不正データは破棄
const outer = _validateOuter(raw);
if (!outer) return null;
const result = schema.safeParse(outer.inputs);
if (!result.success) return null;
🧱 4. SSR × localStorage 対応
Next.jsではSSR中に localStorage が使えない
対策
if (typeof window === "undefined") return;
useEffect(() => {
if (typeof window === "undefined") return;
}, []);
🎯 5. DnD × モバイル対応
センサー設定
useSensor(PointerSensor, {
activationConstraint: { delay: 180 },
});
touch-action問題
-
touch-action: none→ スクロール不可 - DnDとスクロールが競合
解決
- DnDラッパーに
touch-none - ボタンは
touch-manipulation
📱 6. モバイルUI設計
レスポンシブ
{/* モバイル */}
<div className="overflow-x-auto flex" />
{/* PC */}
<div className="grid grid-cols-4" />
ナイトモード
useLayoutEffect(() => {
if (typeof window === "undefined") return;
}, []);
🚀 7. PWA設計
採用理由
- インストール不要
- 即利用可能
- 更新が容易
トレードオフ
- 初回通信は必要
🧠 設計まとめ
| 項目 | 選択 |
|---|---|
| バリデーション | Zod |
| フォーム | React Hook Form |
| 永続化 | localStorage |
| UI | Tailwind |
| DnD | dnd-kit |
🧠 まとめ
- useCalculatorで共通化
- Zodで型安全なバリデーション
- SSR対応で安全なデータアクセス
- DnDとモバイル操作の両立
医療領域のようにミスが許されないドメインでは、
型安全性とバリデーション設計が非常に重要だと実感しました。
一方で、実際の医療現場での活用を考えると、
- 電子カルテ連携
- 個人情報・セキュリティ
- 院内システムとの整合性
といった課題もあり、個人開発だけでは難しい領域も多く存在します。
そのため今後は、
医療DXに関わる方々と対話しながら、現場で使える形へ発展させていきたい
と考えています!
📘 詳細なコード解説はZennで公開中👇
👉 【看護師エンジニアが、現場の「あれがほしい」をNext.jsで形にした話】