HeadlessUIでModalを実装 propsをスプレッド構文で渡してスッキリ!


Modalの実装って意外とめんどくさい。そんな中で発見があったので記事にします。

実装内容

HeadlessUIのModalを使用して複数のModalを実装する。

環境

"@headlessui/react": "^1.5.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"tailwindcss": "^3.0.23",
"react-hook-form": "^7.29.0",

実装のイメージ

このような感じで複数のitem毎にmodal
そして更に各memoを編集するためのmodalを実装します。

modalの状態管理

まず手始めにmodalの状態をuseStateで管理します。

const [isOpen, setIsOpen] = useState(false);

Reactではよく見る実装ですね。しかし、このステートを管理する場所が重要なのです。
最初はトップレベルのコンポーネントでステートを保持していたのですが、そうすると各itemでmodalを開く処理を行ったときにどのmodalを開いたのか分からなくなってしまい、エラーになってしまうのです。

図で見るとこんな感じ

これだとitemList内で管理されているModalの状態管理なので各itemの編集modalを開くためには各itemコンポーネント内でstateを管理しなければならないのです。

コードを書いていく

itemListをmapメソッドを使ってイテレートします。
ポイントとしてはイテレートした中で一つ一つにuseStateでの状態管理を持たせるために一つコンポーネントを挟む事です。Providerという名前にしておきます。(今回はItemModal用なのでItemModalProvider)
関数などの説明は省きます。

import React, { useState, useEffect } from 'react';
import { Modal } from './Modal';
import { nanoid } from 'nanoid';
import { useForm } from 'react-hook-form';
import { ItemModal } from './ItemModal';

export const App = () => {
  const { register, handleSubmit, errors } = useForm();

  const [itemList, setItemList] = useState([]);

  const prevItemList = [
    {
      id: '1',
      name: 'item1',
      memoList: [
        { memoId: '1', memo: 'memo1' },
        { memoId: '2', memo: 'memo2' },
      ],
    },
    {
      id: '2',
      name: 'item2',
      memoList: [
        { memoId: '3', memo: 'memo3' },
        { memoId: '4', memo: 'memo4' },
      ],
    },
  ];

  useEffect(() => {
    setItemList(prevItemList);
  }, []);

  const handleClickAddItem = () => {
    const newItem = {
      id: nanoid(),
      name: `item${itemList.length + 1}`,
      memoList: [{ memoId: nanoid(), memo: 'aaa' }],
    };
    setItemList([...itemList, newItem]);
    console.log('itemが追加されました');
  };

  const handleChangeItemName = (id, name) => {
    const newItemList = itemList.map((item) => {
      if (item.id === id) {
        item.name = name;
      }
      return item;
    });
    setItemList(newItemList);
  };

  const handleEditMemo = (itemId, memoId, name) => {
    const newItemList = itemList.map((item) => {
      if (item.id === itemId) {
        item.memoList = item.memoList.map((memo) => {
          if (memo.memoId === memoId) {
            memo.memo = name;
          }
          return memo;
        });
      }
      return item;
    });
    setItemList(newItemList);
  };

  const onSubmit = (data, id) => {
    console.log(data);
    const newItemList = itemList.map((item) => {
      if (item.id === id) {
        item.memoList.push({ memoId: nanoid(), memo: data[`itemId:${id}`] });
      }
      return item;
    });
    setItemList(newItemList);
  };

  return (
    <>
      <div className='m-4 flex flex-col items-center'>
        <h1 className='text-4xl font-semibold text-gray-700'>
          Headless UI Dialog
        </h1>
        <p>{`itemの数は${itemList.length}です`}</p>
        <button
          className='my-4 p-2 border-solid border-2'
          onClick={handleClickAddItem}
        >
          itemを追加する
        </button>
        {itemList.map((item) => (
          <div
            key={item.id}
            className='mt-4 border-solid border-2 rounded-md p-4'
          >
            <ItemModalProvider
              item={item}
              handleChangeItemName={handleChangeItemName}
              handleSubmit={handleSubmit}
              handleEditMemo={handleEditMemo}
              onSubmit={onSubmit}
              register={register}
            />

            <input
              type='text'
              value={item.name}
              onChange={(e) => handleChangeItemName(item.id, e.target.value)}
            />
            <form onSubmit={handleSubmit((data) => onSubmit(data, item.id))}>
              <input
                className='border-solid border-2'
                {...register(`itemId:${item.id}`)}
              />
              <button type='submit'>memoを追加する</button>
            </form>
            <ul>
              {item.memoList.map((memo) => (
                <li key={memo.memoId}>{memo.memo}</li>
              ))}
            </ul>
          </div>
        ))}
      </div>
    </>
  );
};

const ItemModalProvider = ({
  item,
  handleChangeItemName,
  handleSubmit,
  handleEditMemo,
  onSubmit,
  register,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <ItemModal
        isOpen={isOpen}
        setIsOpen={setIsOpen}
        item={item}
        handleChangeItemName={handleChangeItemName}
        handleSubmit={handleSubmit}
        handleEditMemo={handleEditMemo}
        onSubmit={onSubmit}
        register={register}
      />
      <button
        className='w-64 inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm'
        onClick={() => setIsOpen(!isOpen)}
      >
        Open Modal
      </button>
    </>
  );
};

このようにすることで各item毎のmodalのstateを保持することができました。
やっていることは新たなコンポーネントを挟んでuseStateを追加しているだけなのでやってしまえば簡単です。なのにpropsをわざわざ全部書いててめんどくさい・・・。
そこで活躍するのがスプレッド構文です。

リファクタリング スプレッド構文を使ってpropsを展開

const ItemModalProvider = ({
  item,
  handleChangeItemName,
  handleSubmit,
  handleEditMemo,
  onSubmit,
  register,
}) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <ItemModal
        isOpen={isOpen}
        setIsOpen={setIsOpen}
        item={item}
        handleChangeItemName={handleChangeItemName}
        handleSubmit={handleSubmit}
        handleEditMemo={handleEditMemo}
        onSubmit={onSubmit}
        register={register}
      />
      <button
        className='w-64 inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm'
        onClick={() => setIsOpen(!isOpen)}
      >
        Open Modal
      </button>
    </>
  );
};

この部分非常にわかりづらい。というかめんどくさい。ここって実はこんな風にかけてしまうのです。

const ItemModalProvider = (props) => {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <>
      <ItemModal isOpen={isOpen} setIsOpen={setIsOpen} {...props} />
      <button
        className='w-64 inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:ml-3 sm:w-auto sm:text-sm'
        onClick={() => setIsOpen(!isOpen)}
      >
        Open Modal
      </button>
    </>
  );
};

propsとして受け取った物を分割代入しないでそのまま{...props}としてItemModalコンポーネントへ展開して渡せるのです。このように書くことでItemModalProviderはItemModalのstateを管理する責務を持つだけのコンポーネントであることが分かるため、非常に可読性がよくなります。