💬

【Next.js】コンポーネントと状態管理のファイル分割と設計

2021/09/26に公開

はじめに

今までは実装方法がわからず調査し試行錯誤して実装できた方法を書いたりしていましたが、
今回は実装自体はできているが、より良い実装はないか?という方向性で書いてみたいと思います。

書いていく内容は Next.js で実装された一覧と検索機能を持った画面のコンポーネントと状態管理のファイル分割と設計についてです。

自分は Next.js(React) については書き始めて1年位で、仕事では書いたことなく他PJの構成をほぼ知らないですが、一旦自分の中の設計が落ち着いたので書こうと思った次第です🙏

解説するページ

今回解説するページは自分が実装しているサイトのツイッターの呟き一覧をキャラとLPで検索できるページです。

このページの新旧のファイル構成や状態管理の設計について解説していきます。

https://enjoy-sfv-more.com/lounge/tweet

まず、旧設計のファイル構成を紹介します。

  • components
    • common
      • details.tsx
      • button.tsx
    • lounge
      • tweet
        • character.tsx
        • lp.tsx
        • list.tsx
        • item.tsx
  • lib
    • lounge
      • tweet
        • api.ts
  • pages
    • lounge
      • tweet.tsx

pages/lounge/tweet.tsx を基準にして利用する API やコンポーネントは同じディレクトリ構成にしています(一部共通コンポーネントも存在しています)

ページ

ページのファイルでは呟き一覧・検索する条件(キャラとLP)を useState で管理しています。

各検索のコンポーネント(components/lounge/tweet/character.tsx, components/lounge/tweet/lp.tsx)からユーザーが選んだ条件を受け取り setXXX で状態を保持します。

検索が押されたら保持している検索条件を利用し検索処理を行います。

pages/lounge/tweet.tsx
export default function LoungeTweet() {
  const [tweetList, setTweetList] = useState([]);
  const [allTweetList, setAllTweetList] = useState([]);
  const [selectCharacterList, setSelectCharacterList] = useState([]);
  const [selectLpList, setSelectLpList] = useState([]);

  useEffect(() => {
    (async () => {
      const tmpTweetList = await getTweet(); // lib/lounge/tweet/api.ts
      setTweetList(tmpTweetList);
      setAllTweetList(tmpTweetList);
    })();
  }, []);
  
  const search = () => {
    const searchTweetList = allTweetList.filter((tweet) => {
      // 検索処理
    });
    setTweetList(searchTweetList);
  };
  
  const changeCharacter = (characterList) => {
    setSelectCharacterList(characterList);
  };

  const changeLp = (lpList) => {
    setSelectLpList(lpList);
  };

  return (
    <Layout>
      <H1>ラウンジ募集呟き一覧</H1>
      <div>
        <Character changeCharacter={changeCharacter} /> {/* components/lounge/tweet/character.tsx */}
        <Lp changeLp={changeLp} /> {/* components/lounge/tweet/lp.tsx */}
        <Button onClick={search}>検索</Button> {/* components/common/button.tsx */}
        <LoungeList tweetList={tweetList} /> {/* components/lounge/tweet/list.tsx */}
      </div>
    </Layout>
  );
}

コンポーネント

キャラと LP の検索のコンポーネントはほぼ同じなので LP のコンポーネントについて紹介します。

検索条件を表示し検索条件がクリックされたらコンポーネント内の setXXX で状態を保持し、 props で受け取った関数(今回の例では changeLp)を実行し、親に検索条件を渡します。

components/lounge/tweet/lp.tsx
export default function LoungeTweetLp(props) {
  const { changeLp } = props;
  const [selectLpList, setSelectLpList] = useState([]);

  const clickLp = (lp) => {
    let tmpSelectLpList = [];
    if (selectLpList.includes(lp)) {
      tmpSelectLpList = selectLpList.filter((l) => l.id !== lp.id);
    } else {
      tmpSelectLpList = [...selectLpList, lp];
    }
    setSelectLpList(tmpSelectLpList);
    changeLp(tmpSelectLpList);
  };

  return (
    <div>
      <Details
        summary={(
          <div>
            <span>LP</span>
            <p>
	      {selectLpList.length === 0 ? '指定なし' : selectLpList.map((selectLp) => (
                <p>{selectLp.name}</p>
              ))}
	    </p>
          </div>
        )}
      >
        <ul>
          {lps.map((lp) => (
            <li>
              <input type="checkbox" id={lp.id} onChange={() => clickLp(lp)} checked={selectLpList.includes(lp)} />
              <label htmlFor={lp.id}>{lp.name}</label>
            </li>
          ))}
        </ul>
      </Details>
    </div>
  );
}

解説

まず特徴としてはページ・コンポーネントで状態保持をしています。

これは、実装当時にフックをコンポーネント外に抽出し書くことができるというのを知らなかったので、コンポーネントに書いています。

独自フックの作成はふわっとしか読んでおらず、全然理解していませんでした。

https://ja.reactjs.org/docs/hooks-custom.html

そして、ページ・コンポーネントそれぞれに検索条件の状態が保持されています。

検索処理はページで行っているのでコンポーネントで検索条件を持つ必要はなさそうですが、実装した当時は「選択した条件をコンポーネント内で表示する」 = 「表示する条件をコンポーネント内で持つ」という考えでした。

この設計では下記のような課題があると感じ、リファクタリングすることにしました。

  • ページ・コンポーネントに表示に関する処理と状態に関する処理の2種類書かれている
    • 1つのファイルに複数の役割を持っていて複雑性が高い
    • ファイルが肥大化していく恐れがある
    • 状態のロジックのテストが書きづらい
  • ページ・コンポーネントそれぞれに検索条件を持っている
    • 複雑性が高い

新しい設計では hooks/lounge/useTweet.ts を作り、状態管理に関する処理をコンポーネントの外に出しています。

  • components
    • common
      • details.tsx
      • button.tsx
    • lounge
      • tweet
        • character.tsx
        • lp.tsx
        • list.tsx
        • item.tsx
  • hooks
    • lounge
      • useTweet.ts
  • lib
    • lounge
      • tweet
        • api.ts
  • pages
    • lounge
      • tweet.tsx

フック

ページ・コンポーネントに書かれていた状態の保持に関する処理や検索条件の表示テキスト、検索処理などをまとめています。

旧のコードから基本的にコピペして作られており、新しいことは特別していません。

hooks/lounge/useTweet.ts
export const useLoungeTweet = () => {
  const [tweetList, setTweetList] = useState([]);
  const [allTweetList, setAllTweetList] = useState([]);

  const [selectLpList, setSelectLpList] = useState([]);

  const DEFAULT_TEXT = '指定なし';
  const [selectLpText, setSelectLpText] = useState(DEFAULT_TEXT);

  useEffect(() => {
    (async () => {
      const tmpTweetList = await getTweet();
      setTweetList(tmpTweetList);
      setAllTweetList(tmpTweetList);
    })();
  }, []);

  const search = () => {  
    const searchTweetList = allTweetList.filter((tweet) => {
      // 検索処理
    });
    setTweetList(searchTweetList);
  };

  const setSelectLp = (lp) => {
    let tmpSelectLpList = [];
    if (selectLpList.includes(lp)) {
      tmpSelectLpList = selectLpList.filter((l) => l.id !== lp.id);
    } else {
      tmpSelectLpList = [...selectLpList, lp];
    }
    setSelectLpList(tmpSelectLpList);

    if (tmpSelectLpList.length === 0) {
      setSelectLpText(DEFAULT_TEXT);
    } else {
      setSelectLpText(tmpSelectLpList.map((_lp) => {
        return _lp.name;
      }).join('\n'));
    }
  };

  const checkedLp = (lp) => {
    return selectLpList.includes(lp);
  };

  return {
    tweetList,
    selectLpText,
    setSelectLp,
    checkedLp,
    search,
  };
};

ページ

状態に関する処理や検索処理はなくなり useLoungeTweet から処理を取得し各コンポーネントに渡すだけになりました。

pages/lounge/tweet.tsx
export default function LoungeTweet() {
  const {
    tweetList,
    search,
    setSelectCharacter,
    setSelectLp,
    checkedLp,
    checkedCharacter,
    selectCharacterText,
    selectLpText,
  } = useLoungeTweet();

  return (
    <Layout>
      <H1>ラウンジ募集呟き一覧</H1>
      <div>
        <Character set={setSelectCharacter} checked={checkedCharacter} text={selectCharacterText} />
        <Lp set={setSelectLp} checked={checkedLp} text={selectLpText} />
        <Button onClick={search}>検索</Button>
        <LoungeList tweetList={tweetList} />
      </div>
    </Layout>
  );
}

コンポーネント

状態に関する処理はなくなり props で受け取ったものを利用するだけになりました。

components/lounge/tweet/lp.tsx
export default function LoungeTweetLp(props) {
  const { text, set, checked, } = props;

  return (
    <div>
      <Details
        summary={(
          <div>
            <span>LP</span>
            <p>{text}</p>
          </div>
        )}
      >
        <ul>
          {lps.map((lp) => (
            <li>
              <input type="checkbox" id={lp.id} onChange={() => set(lp)} checked={() => checked(lp)} />
              <label htmlFor={lp.id}>{lp.name}</label>
            </li>
          ))}
        </ul>
      </Details>
    </div>
  );
}

解説

独自フックに状態に関する処理などがまとまり、旧の設計にあった課題が解決されました。

  • ページやコンポーネントは表示に関する処理のみになりシンプルになった
    • ファイルの肥大化が旧と比べて押さえられるようになった
  • テストが旧と比べて書きやすくなった
  • 状態を重複して持たなくなった

再利用について

旧も新もページをまたいだ再利用はあまり考えられていません。

今回解説したのは一覧と検索のページでしたが、このサイトには作成するページもあります。

https://enjoy-sfv-more.com/lounge/create

一覧と検索のページと似たような UI のコンポーネントがありますが、今は別のファイルで実装されています。

別にしている理由としては今回のコードには出てきていないですが、props などの型定義や再利用することでの微妙な差異を吸収するための条件分岐がコンポーネントに増えることを恐れているためです。

このサイトでのページをまたいだ再利用についてはまだ自分の中で良いところに落ちていないので、今後改修していくうちに良いところに落ちたら、また書こうと思います。

最後に

他の設計をもっと知ってみたいので気軽にコメントください👍

追記 2021/10/27

本ブログで作成した hooks のテストについてブログを書きました。

https://zenn.dev/tiwu_dev/articles/0a0fb02aae92c0

Discussion