[PR] 本記事にはプロモーションが含まれています。

【開発記】React + TypeScriptで血液検査記録WEBアプリを作る(第3回:複数Excelファイルの同時取り込みとState管理)

React

前回(第2回)は、SheetJS (xlsx) を使って1つのExcelファイルをブラウザ上で読み込み、画面にテキストとして表示する基本的な処理を実装した。
しかし、血液検査の推移を見るためには、過去数年分のExcelデータをまとめて読み込ませる必要がある。

そこで今回は、<input> タグの機能を拡張し、「複数のExcelファイルを一度に選択し、それらを配列(State)として管理・表示する仕組み」へとプログラムを改修していく。

コードの変更点を順番に見ていこう。

1. データを管理するための「型(Type)」を定義する

複数のファイルを管理するためには、「どのファイルの」「どんなデータか」を整理する箱が必要だ。TypeScriptの強力な機能である interface を使って、データの型(構造)を定義する。 ファイルの上部に以下の型定義を追加する。

TypeScript
// 1行分の検査データ
export interface BloodTestRecord {
  itemName: string;
  value: number | string;
  unit: string;
}

// 読み込んだ1ファイル分のデータ(ファイル名と、その中に含まれる検査データの配列)
export interface UploadedFile {
  fileName: string;
  records: BloodTestRecord[];
}

2. Stateを「配列」に変更する

前回は1つのデータを保持するだけのStateだったが、複数ファイルを取り扱うために、先ほど作った UploadedFile 型の「配列」を初期値とするStateに変更する。

【変更前】

TypeScript
const [data, setData] = useState<any>(null);

【変更後】

TypeScript
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);

3. ファイル選択ボタンを複数対応(multiple)にする

ユーザーがパソコンのフォルダから複数のファイルを同時に選べるようにするには、HTMLの <input> タグに multiple という属性をたった一つ書き足すだけでよい。

【変更箇所】

TypeScript
{/* multiple属性を付けるだけで、ShiftキーやCtrlキーで複数選択が可能になる */}
<input type="file" accept=".xlsx, .xls" multiple onChange={handleFileUpload} />

4. 複数ファイルをループで読み込む処理への書き換え

ここが今回の最大の肝となる。選択された複数のファイルを Array.from で配列に変換し、forEach を使って1つずつ順番に FileReader で読み込んでいく処理に変更する。

TypeScript
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files || files.length === 0) return;

    // 選択された複数のファイルをループ処理
    Array.from(files).forEach(file => {
      const reader = new FileReader();
      
      reader.onload = (event) => {
        const bstr = event.target?.result;
        const workbook = XLSX.read(bstr, { type: 'binary' });
        const worksheet = workbook.Sheets[workbook.SheetNames[0]];
        // Excelの中身をJSON配列として取得
        const rawData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1 });
        
        // (※現段階では仮に、生データをそのままレコードとして保存する)
        const records: BloodTestRecord[] = rawData.map(row => ({
          itemName: String(row[0] || ''),
          value: row[1] || '',
          unit: String(row[2] || '')
        }));

        const dateLabel = file.name.replace('.xlsx', '').replace('.xls', '');
        
        // ★重要:既存の配列(prev)を展開し、新しいファイルデータを末尾に追加する
        setUploadedFiles(prev => [...prev, { fileName: dateLabel, records }]);
      };
      
      reader.readAsBinaryString(file);
    });
  };

ポイントは setUploadedFiles(prev => [...prev, 新しいデータ]) の部分だ。
ReactではStateを直接書き換えることができないため、以前の状態(prev)を展開(...)し、そこに新しいデータをくっつけた新しい配列をセットしている。

5. 完成したコード全体(App.tsx)

ここまでの変更をすべて反映した、第3回時点での App.tsx の全体コードがこちらだ。(※この時点ではまだルーティングやコンポーネント分割を行っていないため、単一ファイルに記述している)

TypeScript
import React, { useState } from 'react';
import * as XLSX from 'xlsx';

// データの型定義
export interface BloodTestRecord {
  itemName: string;
  value: number | string;
  unit: string;
}

export interface UploadedFile {
  fileName: string;
  records: BloodTestRecord[];
}

export default function App() {
  // 複数ファイルを管理するState(配列)
  const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);

  // 複数ファイルの読み込み処理
  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files || files.length === 0) return;

    Array.from(files).forEach(file => {
      const reader = new FileReader();
      reader.onload = (event) => {
        const bstr = event.target?.result;
        const workbook = XLSX.read(bstr, { type: 'binary' });
        const worksheet = workbook.Sheets[workbook.SheetNames[0]];
        const rawData = XLSX.utils.sheet_to_json<any[]>(worksheet, { header: 1 });
        
        // とりあえず1列目を項目名、2列目を値、3列目を単位として抽出
        const records: BloodTestRecord[] = rawData
          .filter(row => row.length > 0) // 空行をスキップ
          .map(row => ({
            itemName: String(row[0] || ''),
            value: row[1] || '',
            unit: String(row[2] || '')
          }));

        const dateLabel = file.name.replace('.xlsx', '').replace('.xls', '');
        
        // 既存のファイルリストに新しいファイルを追加
        setUploadedFiles(prev => [...prev, { fileName: dateLabel, records }]);
      };
      reader.readAsBinaryString(file);
    });
    
    // 同じファイルを再度選べるようにリセット
    e.target.value = '';
  };

  return (
    <div style={{ padding: '20px', fontFamily: 'sans-serif', maxWidth: '800px', margin: '0 auto' }}>
      <h1>血液検査記録アプリプロトタイプ)</h1>
      
      <div style={{ marginBottom: '20px', padding: '20px', backgroundColor: '#f0f4f8', borderRadius: '8px' }}>
        <p style={{ marginTop: 0, fontWeight: 'bold' }}>Excelファイルを選択複数選択可):</p>
        {/* multiple 属性で複数選択を可能に */}
        <input type="file" accept=".xlsx, .xls" multiple onChange={handleFileUpload} />
      </div>

      {/* 読み込んだファイルの数だけループして一覧表示 */}
      {uploadedFiles.map((fileInfo, index) => (
        <div key={index} style={{ marginBottom: '20px', border: '1px solid #ccc', padding: '10px', borderRadius: '4px' }}>
          <h3>📅 {fileInfo.fileName}</h3>
          <p>データ件数: {fileInfo.records.length}</p>
          <ul style={{ maxHeight: '150px', overflowY: 'auto', backgroundColor: '#f9f9f9', padding: '10px' }}>
            {fileInfo.records.map((record, rIndex) => (
              <li key={rIndex}>
                {record.itemName}: {record.value} {record.unit}
              </li>
            ))}
          </ul>
        </div>
      ))}
    </div>
  );
}

ブラウザで確認し、複数のファイルをドラッグ&ドロップ(またはファイル選択ダイアログで複数選択)したときに、それぞれのファイル名と中身がリストとして縦に並んで表示されれば成功。

しかし、今のままでは生データがそのまま表示されているだけで見栄えも悪く、必要な「検査項目」だけをうまく抽出できていない。 次回(第4回)は、この取り込んだ生データから、必要な項目や数値を動的に探し出して綺麗に整形するロジックを実装していく。フ化に必要な「検査項目」と「結果」だけを抽出して扱いやすいJSON形式に整形する処理を実装していく。

コメント