🦀

laravel10¦フレームワークなしのReactでバリデーションエラー時の入力フォームの値を保持する

2024/04/07に公開

背景

ほとんどがLaravelのbladeを使用しており、一部だけReactとTypeScriptで書かれているプロジェクトに入った時に結構手こずったのでわかる範囲でまとめてみました。
自分もアサインするまでは恥ずかしながら知らなかったのですがReactではフレームワークを使用することを推奨しています。
やはり部分部分で厳しいなと思ったのですが、カバーできることもあると思ったのでtipsとして残しておこうと思いました。

やりたいこと

おそらくこういった場合に悩むことのひとつはバリデーションエラー時に入力欄に値を表示させたいが、React側に渡すのが難しいことではないでしょうか
sessionStorageを使用しているパターンを見かけましたが、あまり使用したくなく、使用しなくても解決できたのでその方法になります。

今回はファイルアップロードについてですが、同様の方法で<input type="text"/>のような入力欄にも対応可能と思われます。

環境

php 8.1
Laravel 10
TypeScript 5.3
React 18.2
react-dom 18.2
vite 4.0
mui-file-input 4.0

実装

前提としてファイルアップロードとDB保存はAPIになっており、複数箇所で使用するためblade側ではblade componentを使用し、ファイルアップロードのコンポーネントを作成し使用することにしました。

またpost時のバリデーションとしてfile_idがチェックされるため、file_idをサーバー側へ送る必要があります。

サーバー側でファイルの情報を返してもらえない場合の実装

React

ReactではuseStateを使用し、ファイルを選択したらAPIをコールし、S3へ保存->保存が成功したらDBへ保存、返却値のidをsaveId、ファイル自体をfileDataに入れています。

type FileType = {
  id: number;
  fileData: File | null;
  saveId: string; // DB保存APIの返却値のID
}

export const FileUpload = (props: FileUploadProps) => {
  const [files, setFiles] = useState<FileType[]>(getInitialParams(oldFileIds))
  // 省略
  return (
    <>
    {FileSystem.map((file) => {
      <div key={file.id}>
        <MuiFileInput
        //省略
        />
        {file.saveId && (
          <>
            <input
              type="hidden"
              name={
                isMultiple
                  ? `${name}[${file.id}][file_id]`
                  : `${name}[file_id]`
              }
              value={file.saveId}
              readOnly
            />
            <input
              type="hidden"
              name={
                isMultiple
                  ? `${name}[${file.id}][file_name]`
                  : `${name}[file_name]`
              }
              value={file.fileData?.name}
              readOnly
            />
          </>
        )}
      </div>
    })}
    </>
  )
}

const elements = document.querySelectorAll('[id^="file-upload-"]')
if (elements) {
 elements.forEach((elem) => {
  const props = elem.getAttribute('data-props')
  const reactProps: FileUploadProps = props ? Json.parse(props) : null
  const root = createRoot(element)
  root.render(<FileUpload {...reactProps} />)
 })
}

今回のファイルアップロードはひとつだけファイルをアップロードする場合と、複数のファイルをアップロードする場合があり、ひとつだけファイルをアップロードする場合は配列の中に連想配列がある形でバリデーションがかかっています。

public function rules(): array
{
    return [
        'user.file_id' => ['required', 'string', 'exists:....']
    ];
}

複数のファイルをアップロードする場合は

public function rules(): array
{
    return [
        'user_file_ids.*' => [...]
    ];
}

blade(controllerから呼ばれるファイル)

blade側のformの中にファイルアップロード・削除のコンポーネントを入れます。

  <form method="POST" action="{{ route('user.upload') }}">
    @csrf
    <x-file
      label="名前"
      name="user"
      // 省略
      :isMultiple="false"
      :oldFileIds="is_null(old('user.file_id')) ? [] : [['file_id' => old('user.file_id'), 'file_name' => old('user.file_name')]]"
    />
  </form>

nameはReactの<input type="hidden" readOnly/>の箇所で使用されているnameに該当します。
oldFileIdsはバリデーションエラーの時に使用するもので、バリデーションエラー時にfile_idfile_nameをReact側へ渡します。

view component

class Upload extends Component
{
    public function __construct(
        public string $name = '',
        public string $label = '',
        // 省略
        public bool $isMultiple = true,
        public ?array $oldFileIds = [],
    ) {
      $this->name = $name;
      $this->label = $label;
      $this->isMultiple = $isMultiple;

      $oldIds = [];
      if (is_array($oldFileIds) && ! empty($oldFileIds)) {
        foreach ($oldFileIds as $oldFiles) {
          $oldIds[] = $oldFiles;
        }
      }
      $this->oldFileIds = $oldIds;
    }
}

$oldFileIdsをforeachで何をしているのかというと、そのまま$oldFileIdsを渡すとオブジェクトの中にオブジェクトが入ってしまっており、下記のような配列の中にオブジェクトが入っているようにしたかったためこうしています。

[
  { file_id: xxxxxxxx, file_name: yyyyyy.png }
]

blade(component)

/components/fileにview componentの変数をセットします。

<div
    id="file-upload-{{ $name }}"
    data-props="{{ json_encode([
        'name' => $name,
        'isMultiple' => $isMultiple,
        'oldFileIds' => $oldFileIds,
    ]) }}"   
>

ReactのuseStateの初期値getInitialParamsでしていること

getInitialParamsはoldFileIdsがある場合は、ファイルがあるように擬似的にファイルを作成し、返します。oldFileIdsがない場合は初期値を返すようにしています。

const getInitialParams = (oldFileIds) => {
  if (!oldFileIds || oldFileIds.length === 0) {
    return [{ id: 1, fileData: null, saveId: null}]
  }
  const oldParams: FileType[] = [];
  oldFileIds.map((old, index) => {
    oldParams.push(convertOldInitialValue(old.file_id, old.file_name, index))
  })
  return oldParams 
}

convertOldInitialValueで擬似的にファイルを作成していますが、ただのファイル名の配列でも良いかもです。

const mimetypes = [
  { extension: 'jpeg', mimetype: 'image/jpeg' }
  // 省略
]

const convertOldInitialValue = (
 fileId: string,
 fileName: string,
 index: number
): FileType => {
 const extension = getExtension(fileName) // ファイル名から'png'とかをとってくる
 const mime = mimetypes.find((obj) => obj.extension === extension)?.mimetype ?? 'image/jpg'

 return {
  id: index,
  fileData: new File([''], fileName, { type: mime }),
  saveId: fileId
 }
}

こうすることでsessionStrageを使用しなくてもバリデーションエラー時にファイルの入力枠にファイルがあるように見せることができます。

サーバー側でファイルの情報を返してもらえる場合の実装

APIでファイルアップロード〜DBへの保存もしているので、サーバー側からfile_idやfile_nameを返してもらえる場合はもう少しシンプルになります。

React

サーバー側からfile_nameを返してもらえるため、file_nameを送る必要はなく、file_idだけ送れば良いです。

{file.saveId && (
  <>
    <input
      type="hidden"
      name={
        isMultiple
          ? `${name}[${file.id}]`
          : `${name}[file_id]`
      }
      value={file.saveId}
      readOnly
    />
  </>
)}

blade(controllerから呼ばれるファイル)

oldFileIdsにfile_idとfile_nameが入っている配列fileを渡すようにします。

  <form method="POST" action="{{ route('user.upload') }}">
    @csrf
    <x-file
      label="名前"
      name="user"
      // 省略
      :isMultiple="false"
      :oldFileIds="empty($file) ? [] : $file"
    />
  </form>

🎩 🎩 🎩

実装している時はsessionStrageを使用するかすごく悩みましたが、あまり使いたくないなと思い どのような方法で実装すべきか非常に悩みました。。。
何かしらライブラリを使用しても良さそうですが、ライブラリを使用するよりも今あるものでどうにかならないかな〜と考えた結果このようになりました。
もしかしたらもっと良い方法があるのかもですが ひとまず自分には今はこれが精一杯だったので精進したいです( ´~` )

Discussion