🤖

仕様変更に強くしたい App Router

2023/08/20に公開

まずはサンプルコードから

以下のコードを見ていただきたい。この記事では、この巨大なソースをリファクタリングします。

要素技術

  • React/Next.Js(App Router)
  • tailwindcss
  • GraphQL/Postgraphile
  • Mantine UI
/app/item/page.tsx
'use client';

import { useState } from 'react';
import { Table, TextInput, Modal, Button } from '@mantine/core';
import { useGetItemListQuery, useGetCategoryQuery, useRegisterItemMutation } from '@postgraphile/graphql';

const Page = () => {
  // 登録用
  const [itemName, setItemName] = useState('');
  const [category, setCategory] = useState('');
  const [price, setPrice] = useState(0);

  // 検索用
  // 商品名の前方一致
  const [itemNameCriteria, setItemNameCriteria] = useState('');
  const [categoryCriteria, setCategoryCriteria] = useState('');
  const [minPriceCriteria, setMinPriceCriteria] = useState(0);
  const [maxPriceCriteria, setMaxPriceCriteria] = useState(0);
  const [isOpen, setIsOpen] = useState(false);

  const { data } = useGetItemListQuery({
    variables: {
      itemName: itemNameCriteria;
      category: categoryCriteria;
      minPrice: minPriceCriteria;
      maxPrice: maxPriceCriteria;
    }
  });

  const { data: categoryData } = useGetCategoryQuery({
    variables: {
      category: category;
    }
  });

  const { mutation } = useRegisterItemMutation();

  const registerHander = () => {
    // 入力が空の場合は、処理終了
    if(itemName === '') return;
    if(category === '') return;

    if(categoryData.category.length === 0) {
      // カテゴリが存在しない場合は、処理終了
      return;
    }

    mutation({
      variables: {
        itemName: itemName;
        category: category;
        price: price;
      }
    })
  };

  return (
    <div>
      {/* 以下は、検索するコンポーネント */}
      <div>
        {/* 検索条件 */}
        <div>
          <div className={'flex grid mb-4'}>
            <div>商品名</div>
            <TextInput
              type={'text'}
              value={itemNameCriteria}
              onChange={setItemNameCriteria}
            />
          </div>
          <div className={'flex grid mb-4'}>
            <div>カテゴリ</div>
            <TextInput
              type={'text'}
              value={categoryCriteria}
              onChange={setCategoryCriteria}
            />
          </div>
          <div className={'flex grid mb-4'}>
            <div>金額</div>
            <TextInput
              type={'numeric'}
              value={minPriceCriteria}
              onChange={setMinPriceCriteria}
            />
            <span></span>
            <TextInput
              type={'numeric'}
              value={maxPriceCriteria}
              onChange={setMaxPriceCriteria}
            />
          </div>
        </div>
        <div className='flex'>
          <Button onClick={() => setIsOpen(true))}>登録へ</Button>
        </div>
        {/* 結果一覧 */}
        <Table>
          <thead>
            <tr>
              <th>商品名</th>
              <th>カテゴリ</th>
              <th>金額</th>
            </tr>
          </thead>
          <tbody>
            {data?.items?.map(({itemName, category, price}) => {
              (<tr>
                <td>{itemName}</td>
                <td>{category}</td>
                <td>{price}</td>
              </tr>
            )}) ?? []}
          </tbody>
        </Table>
      </div>
      {/* 以下は、登録するコンポーネント */}
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
        <div>
          <div className={'flex grid mb-4'}>
            <div>商品名</div>
            <TextInput
              type={'text'}
              placeholder={'商品名を入力してください。'}
              value={itemName}
              onChange={setItemName}
            />
          </div>
          <div className={'flex grid mb-4'}>
            <div>カテゴリ</div>
            <TextInput
              type={'text'}
              placeholder={'カテゴリを入力してください。'}
              value={category}
              onChange={setCategory}
            />
          </div>
          <div className={'flex grid mb-4'}>
            <div>金額</div>
            <TextInput
              type={'numeric'}
              placeholder={'金額を入力してください。'}
              value={price}
              onChange={setPrice}
            />
          </div>
          <div>
            <Button onClick={registerHandler}>登録</Button>
          </div>
        </div>
      </Modal>
    </div>
  )
}

export default Page;

ソースの説明をしておきます。

  1. 商品を検索/登録できる
  2. 商品は、商品名、カテゴリ、金額で構成される
  3. 検索条件を入力すると、リアルタイムで検索一覧が更新される
  4. 登録はモーダルが起動する

古典的に、「動けばそれで OK」な開発をするところは、現在も多数ありますし、私自身少し前までこういう実装をしていました。
ただ、これを見て思うことは、「どこから見ようか」「どうなっているのか」「壊れやすそう」など、ネガティブな感想が多いのではないかと思います。何が問題かを考えると、一番は長すぎること です。今回は、フロントエンドを取り上げていますが、バックエンドでも同じことで、長すぎる場合は、疑った方がいいです。

Method 1 ページコンポーネントの分離

このページには、検索と登録の 2 つのページ・機能があるので、それらを分離できないか考えます。検索ページから登録ページを呼び出しているので、登録ページを分離してみます。

/app/item/page.tsx
'use client';

import { useState } from 'react';
+ import { Table, TextInput, Modal } from '@mantine/core';
+ import { useGetItemListQuery } from '@postgraphile/graphql';
- import { Table, TextInput, Modal, Button } from '@mantine/core';
- import { useGetItemListQuery, useGetCategoryQuery, useRegisterItemMutation } from '@postgraphile/graphql';

const Page = () => {
-  // 登録用
-  const [itemName, setItemName] = useState('');
-  const [category, setCategory] = useState('');
-  const [price, setPrice] = useState(0);
-
  // 検索用
  // 商品名の前方一致
  const [itemNameCriteria, setItemNameCriteria] = useState('');
  const [categoryCriteria, setCategoryCriteria] = useState('');
  const [minPriceCriteria, setMinPriceCriteria] = useState(0);
  const [maxPriceCriteria, setMaxPriceCriteria] = useState(0);
  const [isOpen, setIsOpen] = useState(false);

  const { data } = useGetItemListQuery({
    variables: {
      itemName: itemNameCriteria;
      category: categoryCriteria;
      minPrice: minPriceCriteria;
      maxPrice: maxPriceCriteria;
    }
  });

-  const { data: categoryData } = useGetCategoryQuery({
-    variables: {
-      category: category;
-    }
-  });
-
-  const { mutation } = useRegisterItemMutation();
-
-  const registerHander = () => {
-    // 入力が空の場合は、処理終了
-    if(itemName === '') return;
-    if(category === '') return;
-
-    if(categoryData.category.length === 0) {
-      // カテゴリが存在しない場合は、処理終了
-      return;
-    }
-
-    mutation({
-      variables: {
-        itemName: itemName;
-        category: category;
-        price: price;
-      }
-    })
-  };
-
  return (
    <div>
      {/* 以下は、検索するコンポーネント */}
      <div>
        {/* 検索条件 */}
        <div>
          <div className={'flex grid mb-4'}>
            <div>商品名</div>
            <TextInput
              type={'text'}
              value={itemNameCriteria}
              onChange={setItemNameCriteria}
            />
          </div>
          <div className={'flex grid mb-4'}>
            <div>カテゴリ</div>
            <TextInput
              type={'text'}
              value={categoryCriteria}
              onChange={setCategoryCriteria}
            />
          </div>
          <div className={'flex grid mb-4'}>
            <div>金額</div>
            <TextInput
              type={'numeric'}
              value={minPriceCriteria}
              onChange={setMinPriceCriteria}
            />
            <span></span>
            <TextInput
              type={'numeric'}
              value={maxPriceCriteria}
              onChange={setMaxPriceCriteria}
            />
          </div>
        </div>
        <div className='flex'>
          <Button onClick={() => setIsOpen(true))}>登録へ</Button>
        </div>
        {/* 結果一覧 */}
        <Table>
          <thead>
            <tr>
              <th>商品名</th>
              <th>カテゴリ</th>
              <th>金額</th>
            </tr>
          </thead>
          <tbody>
            {data?.items?.map(({itemName, category, price}) => {
              (<tr>
                <td>{itemName}</td>
                <td>{category}</td>
                <td>{price}</td>
              </tr>
            )}) ?? []}
          </tbody>
        </Table>
      </div>
      {/* 以下は、登録するコンポーネント */}
      <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
+        <RegisterItem/>
-        <div>
-          <div className={'flex grid mb-4'}>
-            <div>商品名</div>
-            <TextInput
-              type={'text'}
-              placeholder={'商品名を入力してください。'}
-              value={itemName}
-              onChange={setItemName}
-            />
-          </div>
-          <div className={'flex grid mb-4'}>
-            <div>カテゴリ</div>
-            <TextInput
-              type={'text'}
-              placeholder={'カテゴリを入力してください。'}
-              value={category}
-              onChange={setCategory}
-            />
-          </div>
-          <div className={'flex grid mb-4'}>
-            <div>金額</div>
-            <TextInput
-              type={'numeric'}
-              placeholder={'金額を入力してください。'}
-              value={price}
-              onChange={setPrice}
-            />
-          </div>
-          <div>
-            <Button onClick={registerHandler}>登録</Button>
-          </div>
-        </div>
      </Modal>
    </div>
  )
}

export default Page;

/app/item/RegisterItem.tsx
+ 'use client';
+
+ import { useState } from 'react';
+ import { TextInput, Button } from '@mantine/core';
+ import { useGetCategoryQuery, useRegisterItemMutation } from '@postgraphile/graphql';
+
+ export const RegisterItem = () => {
+   // 登録用
+   const [itemName, setItemName] = useState('');
+   const [category, setCategory] = useState('');
+   const [price, setPrice] = useState(0);
+
+   const { data: categoryData } = useGetCategoryQuery({
+     variables: {
+       category: category;
+     }
+   });
+
+   const { mutation } = useRegisterItemMutation();
+
+   const registerHander = () => {
+     // 入力が空の場合は、処理終了
+     if(itemName === '') return;
+     if(category === '') return;
+
+     if(categoryData.category.length === 0) {
+       // カテゴリが存在しない場合は、処理終了
+       return;
+     }
+
+     mutation({
+       variables: {
+         itemName: itemName;
+         category: category;
+         price: price;
+       }
+     })
+   };
+
+   return (
+     <div>
+       <div className={'flex grid mb-4'}>
+         <div>商品名</div>
+         <TextInput
+           type={'text'}
+           placeholder={'商品名を入力してください。'}
+           value={itemName}
+           onChange={setItemName}
+         />
+       </div>
+       <div className={'flex grid mb-4'}>
+         <div>カテゴリ</div>
+         <TextInput
+           type={'text'}
+           placeholder={'カテゴリを入力してください。'}
+           value={category}
+           onChange={setCategory}
+         />
+       </div>
+       <div className={'flex grid mb-4'}>
+         <div>金額</div>
+         <TextInput
+           type={'numeric'}
+           placeholder={'金額を入力してください。'}
+           value={price}
+           onChange={setPrice}
+         />
+       </div>
+       <div>
+         <Button onClick={registerHandler}>登録</Button>
+       </div>
+     </div>
+     );
+ }

ページ・機能ごとにファイルが分かれるだけでも、だいぶ見通しはよくなります。

Method 2 App Router の活用

Next.js v13 から App Router が実装されました。
https://nextjs.org/docs/app/building-your-application

この機能は、より直感的な構成に実装できる点、汎用性の高さが魅力的だと感じています。これを活用することで、以下のようにリファクタリングできます。

/app/item/layout.tsx
+ 'use client';
+
+ import { Button} from '@mantine/core';
+
+ const Layout = ({search, register}: {
+   search: ReactNode;
+   register: ReactNode;
+ }) => {
+   const [isOpen, setIsOpen] = useState(false);
+
+   return (
+     <div>
+       <div>{search}</div>
+       <div className='flex'>
+         <Button onClick={() => setIsOpen(true))}>登録へ</Button>
+       </div>
+       <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
+         {register}
+       </Modal>
+     </div>
+   );
+ };
+
+ export default Layout;
/app/item/RegisterItem.tsx -> /app/item/@register/RegisterItem.tsx

/app/item/@register/page.tsx
+ import { RegisterItem } from './RegisterItem';
+
+ const Page = () => <RegisterItem/>
+
+ export default Page;

これは、Parallel Routes という機能で、コンポーネントを並列で呼び出すことができます。フォルダ名の先頭に @をつけることで実現できます。ここでは解説しないので、詳しくは公式をご覧ください。
https://nextjs.org/docs/app/building-your-application/routing/parallel-routes

もう一方の @search についても同様の実装をすればよいのですが、ここで気づくことがあります。/app/item/@register/page.tsx は、中身を呼び出しているだけになりました。App Router における page.tsx とはルーティングを責務とするファイルです。どの言語においても、それぞれのファイル・コンポーネントは責務外をしないようにすることで、可読性向上、testable なコードを実現できます。

/app/item/@search/page.tsx
+ import { SearchItem } from './SearchItem';
+
+ const Page = () => <SearchItem/>
+
+ export default Page;
/app/item/page.tsx -> /app/item/@search/SearchItem.tsx
'use client';

import { Table, TextInput, Modal } from '@mantine/core';
import { useGetItemListQuery } from '@postgraphile/graphql';

+ export const SearchItem = () => {
- const Page = () => {
   // 商品名の前方一致
   const [itemNameCriteria, setItemNameCriteria] = useState('');
   const [categoryCriteria, setCategoryCriteria] = useState('');
   const [minPriceCriteria, setMinPriceCriteria] = useState(0);
   const [maxPriceCriteria, setMaxPriceCriteria] = useState(0);
-  const [isOpen, setIsOpen] = useState(false);

  const { data } = useGetItemListQuery({
    variables: {
      itemName: itemNameCriteria;
      category: categoryCriteria;
      minPrice: minPriceCriteria;
      maxPrice: maxPriceCriteria;
    }
  });

   return (
     <div>
       {/* 以下は、検索するコンポーネント */}
       <div>
         {/* 検索条件 省略 */}
         <div>...</div>
-         <div className='flex'>
-           <Button onClick={() => setIsOpen(true))}>登録へ</Button>
-         </div>
         {/* 結果一覧 省略 */}
         <Table>...</Table>
-         <Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
-           <RegisterItem/>
-         </Modal>
       </div>
     </div>
   )
 }
-
- export default Page;

Parallel Routes を使うことで、検索ページから登録ページの呼び出しをすることがなくなりました。互いに依存関係にあるコンポーネントを別 page.tsx にするのは、また手間要りますが、今回の場合だと依存関係がないもの同士だったので、このような分離が可能です。

Method 3 外部ライブラリの隠蔽

今回のメインといってもいい内容です。次の資料を見てください。
https://mantine.dev/pages/changelog/

UI ライブラリの Mantine は、リリース頻度が高く、バージョンアップのたびに変更の手間が大きいです。他の各ライブラリに関しても(特にフロントエンドは)バージョンアップ頻度が多く、開発生産性に大きく影響します。

UI ライブラリの例を出しましたが、他にも API Gateway 機構やフレームワーク、グローバルステート管理など、ほとんどのライブラリのバージョンアップが頻発されているようです。

脆弱性対応が含まれることもあるので、バージョンアップしないという選択肢は採りたくないので、最小限に手間を省いた方法を考えます。

先に取り上げたソースを見てください。

/app/item/@register/RegisterItem.tsx
'use client';

import { useState } from 'react';
import { TextInput, Button } from '@mantine/core';

export const RegisterItem = () => {
  {/* 省略 */}

  return (
    <div>
      <div className={'flex grid mb-4'}>
        <div>商品名</div>
        <TextInput
          type={'text'}
          placeholder={'商品名を入力してください。'}
          value={itemName}
          onChange={setItemName}
        />
      </div>
      <div className={'flex grid mb-4'}>
        <div>カテゴリ</div>
        <TextInput
          type={'text'}
          placeholder={'カテゴリを入力してください。'}
          value={category}
          onChange={setCategory}
        />
      </div>
      <div className={'flex grid mb-4'}>
        <div>金額</div>
        <TextInput
          type={'numeric'}
          placeholder={'金額を入力してください。'}
          value={price}
          onChange={setPrice}
        />
      </div>
      <div>
        <Button onClick={registerHandler}>登録</Button>
      </div>
    </div>
    );
}

このソースには、外部ライブラリとして、@mantine/core が import されています。
仮にですが、バージョンアップにより、TextInput の I/F が変わったり、コンポーネントの名称が変わったとすると、修正箇所は、3 箇所も修正することになります。当然ながら、他にも使用している箇所がありそうなので、かなり膨大な修正になることがわかります。

core なコンポーネントから直接外部ライブラリを参照しないようにすることで、修正コストを軽減することが可能です。これは、Clean Architecture でもよく聞く「関心の分離」に基づく考え方です。忠実に採用するならば、「依存関係逆転の原則」を使うのですが、そこまでする必要もないので、以下のようにします。

/components/ui/index.ts
+ export { TextInput } from './TextInput';
+ export { Button } from './Button';
/components/ui/TextInput.tsx
+ import { TextInput as MantineTextInput } from '@mantine/core';
+
+ type Props = {
+   label: string;
+   value: string | number;
+   onChange: (value: string | number) => void;
+   type?: 'text' | 'numeric';
+ };
+
+ export const TextInput = ({
+   label, value, onChange, type = 'text'
+ }: Props) => {
+   const placeholder = label + 'を入力してください。';
+
+   return (
+     <div className={'flex grid mb-4'}>
+       <div>{label}</div>
+       <MantineTextInput
+         type={type}
+         placeholder={placeholder}
+         value={value}
+         onChange={onChange}
+       />
+     </div>
+   );
+ }
/components/ui/Button.tsx
+ // 割愛
/app/item/@register/RegisterItem.tsx
'use client';

import { useState } from 'react';
+ import { TextInput, Button } from '@/components/ui';
- import { TextInput, Button } from '@mantine/core';

export const RegisterItem = () => {
  {/* 省略 */}

  return (
    <div>
+       <TextInput
+         label={'商品名}
+         value={itemName}
+         onChange={setItemName}
+       />
-       <div className={'flex grid mb-4'}>
-         <div>商品名</div>
-         <TextInput
-           type={'text'}
-           placeholder={'商品名を入力してください。'}
-           value={itemName}
-           onChange={setItemName}
-         />
-       </div>
+       <TextInput
+         label={'カテゴリ}
+         value={category}
+         onChange={setCategory}
+       />
-       <div className={'flex grid mb-4'}>
-         <div>カテゴリ</div>
-         <TextInput
-           type={'text'}
-           placeholder={'カテゴリを入力してください。'}
-           value={category}
-           onChange={setCategory}
-         />
-       </div>
+       <TextInput
+         label={'金額}
+         type={'numeric'}
+         value={price}
+         onChange={setPrice}
+       />
-       <div className={'flex grid mb-4'}>
-         <div>金額</div>
-         <TextInput
-           type={'numeric'}
-           placeholder={'金額を入力してください。'}
-           value={price}
-           onChange={setPrice}
-         />
-       </div>
      <div>
+         <Button type={'register'} onClick={registerHandler}/>
-         <Button onClick={registerHandler}>登録</Button>
      </div>
    </div>
    );
}

外部ライブラリを隠蔽し、案件用にカスタマイズすることのメリットは以下だと考えます。以下では、UI コンポ というようにします。

  1. バージョンアップによる、改修が発生した場合は、呼び出しているページではなく UI コンポの修正で済む。
  2. StoryBook 等の UI テストは、UI コンポの確認で済む。
  3. Mantine でない別のライブラリに置き換えが容易である。
  4. エンジニア間のばらつきを防ぐことができる。

3 について補足があります。

+ import { TextInput, Button } from '@/components/ui';
- import { TextInput, Button } from '@mantine/core';

階層はしっかりと意識する必要があります。今回の場合は、各 UI コンポで、Mantine のコンポーネントを使用しています。だからといって、/components/mantine/ のような外部ライブラリがあらわになる名称を使っては台無しです。Mantine からライブラリを変更する場合、または複数の UI ライブラリを使う場合など、呼び出し元であるページコンポーネントも変更する必要性が出てきます。変えなくてもいいものを変えずに済む工夫も必要になります。

さらに、4 については、効果が大きく、使えるものが整理されているだけで、エンジニアが考える分量を減らしてくれます。統制を取るために、ドキュメントを用意したりすることもあるでしょうが、そういった手間を減らします。

これは、hooks にも言える話で、例えばアプリ内で使用される ID があるとします。zennId としましょう。これは、zenn の記事を特定するもので、Client に保存して使うものとします。これを保存するとき、取り出すときの実装は、上記の応用で以下のようにできます。

/hooks/zenn/useZennHolder.ts
import { useZennIdSelector, zennState } from '@/global-state/recoil/zenn';

export const useZennHolder = () => {
  const [zennId, setZennId] = useZennIdSelector(zennState);

  return {
    zennId,
    saveZennId: setZennId
  }
}
/app/zenn/Sample.tsx
'use client';

import { useState } from 'react';
import { useZennHolder } from '@/hooks/zenn/useZennHolder';
import { TextInput } from '@/components/ui';

export const Sample = () => {
  const [ zennIdInput, setZennIdInput ] = useState('');

  const { zennId, saveZennId } = useZennHolder();

  return (
    <div>
      <TextInput
        label={'Zenn ID'}
        value={zennIdInput}
        onChange={saveZennIdInput}
      />

      <div>保存されたID ===>>> {zennId}</div>
    </div>
  );
}

この実装では、Recoil を使用した保存をしていますが、別のライブラリ Jotai に変更になっても、Cookie になっても、useZennHolder だけの修正であるのはもちろんのこと、さらには各ページで、保存方法を実装しないようにすることで、エンジニアによる zennId の保存方法がばらつくのを防いでいます。

以上を受けて、ソースはこのようになります。

/hooks/item/useGetItemList
+ // 割愛
/app/item/@search/SearchItem.tsx
'use client';

import { useState } from 'react';
+ import { Table, TextInput, RangeTextInput } from '@/components/ui';
- import { Table, TextInput, RangeTextInput } from '@mantine/core';
+ import { useGetItemList } from '@/hooks/item/useGetItemList';
- import { useGetItemListQuery } from '@postgraphile/graphql';

export const SearchItem = () => {
  // 商品名の前方一致
  const [itemNameCriteria, setItemNameCriteria] = useState('');
  const [categoryCriteria, setCategoryCriteria] = useState('');
  const [minPriceCriteria, setMinPriceCriteria] = useState(0);
  const [maxPriceCriteria, setMaxPriceCriteria] = useState(0);

+   const { data } = useGetItemList({
-   const { data } = useGetItemListQuery({
-     variables: {
      itemName: itemNameCriteria;
      category: categoryCriteria;
      minPrice: minPriceCriteria;
      maxPrice: maxPriceCriteria;
-     }
  });

  return (
    <div>
      <div>
+         <TextInput
+           label={'商品名'}
+           value={itemNameCriteria}
+           onChange={setItemNameCriteria}
+         />
-         <div className={'flex grid mb-4'}>
-           <div>商品名</div>
-           <TextInput
-             type={'text'}
-             value={itemNameCriteria}
-             onChange={setItemNameCriteria}
-           />
-         </div>
+         <TextInput
+           label={'カテゴリ'}
+           value={categoryCriteria}
+           onChange={setCategoryCriteria}
+         />
-         <div className={'flex grid mb-4'}>
-           <div>カテゴリ</div>
-           <TextInput
-             type={'text'}
-             value={categoryCriteria}
-             onChange={setCategoryCriteria}
-           />
-         </div>
+         <RangeTextInput
+           label={'金額'}
+           type={'numeric'}
+           value={[minPriceCriteria, maxPriceCriteria]}
+           onChange={[setMinPriceCriteria, setMaxPriceCriteria]}
+         />
-         <div className={'flex grid mb-4'}>
-           <div>金額</div>
-           <TextInput
-             type={'numeric'}
-             value={minPriceCriteria}
-             onChange={setMinPriceCriteria}
-           />
-           <span></span>
-           <TextInput
-             type={'numeric'}
-             value={maxPriceCriteria}
-             onChange={setMaxPriceCriteria}
-           />
-         </div>
      </div>
      <Table
+         header={['商品名', 'カテゴリ', '金額']}
+         data={data}
+       />
-         <thead>
-           <tr>
-             <th>商品名</th>
-             <th>カテゴリ</th>
-             <th>金額</th>
-           </tr>
-         </thead>
-         <tbody>
-           {data?.items?.map(({itemName, category, price}) => {
-             (<tr>
-               <td>{itemName}</td>
-               <td>{category}</td>
-               <td>{price}</td>
-             </tr>
-           )}) ?? []}
-         </tbody>
-       </Table>
    </div>
  )
}
/hooks/item/useRegisterItem
+ type RegisterItemArgs = {
+   itemName: string;
+   category: string;
+   price: number;
+ };
+
+ export const useRegisterItem = ({
+  itemName,
+  category,
+  price
+ }: RegisterItemArgs) => {
+   const { mutation } = useRegisterItemMutation();
+   const { data: categoryData } = useGetCategoryQuery({
+      variables: {
+        category;
+      }
+    });
+
+   const registerHander = () => {
+      // 入力が空の場合は、処理終了
+      if(itemName === '') return;
+      if(category === '') return;
+
+      if(categoryData.category.length === 0) {
+        // カテゴリが存在しない場合は、処理終了
+        return;
+      }
+
+      mutation({
+        variables: {
+          itemName;
+          category;
+          price;
+        }
+      })
+   };
+
+   return {
+     registerHandler
+   }
+ }
/app/item/@register/RegisterItem.tsx
'use client';

import { useState } from 'react';
import { TextInput, Button } from '@/components/ui';
+ import { useRegisterItem } from '@/hooks/item/useRegisterItem';
- import { useGetCategoryQuery, useRegisterItemMutation } from '@postgraphile/graphql';

 export const RegisterItem = () => {
   const [itemName, setItemName] = useState('');
   const [category, setCategory] = useState('');
   const [price, setPrice] = useState(0);

+   const { registerHandler } = useRegisterItem({
+     itemName,
+     category,
+     price
+   });

-   const { data: categoryData } = useGetCategoryQuery({
-     variables: {
-       category: category;
-     }
-   });
-
-   const { mutation } = useRegisterItemMutation();
-
-   const registerHander = () => {
-     // 入力が空の場合は、処理終了
-     if(itemName === '') return;
-     if(category === '') return;
-
-     if(categoryData.category.length === 0) {
-       // カテゴリが存在しない場合は、処理終了
-       return;
-     }
-
-     mutation({
-       variables: {
-         itemName: itemName;
-         category: category;
-         price: price;
-       }
-     })
-   };

  return (
    <div>
       <TextInput
         label={'商品名}
         value={itemName}
         onChange={setItemName}
       />
       <TextInput
         label={'カテゴリ}
         value={category}
         onChange={setCategory}
       />
       <TextInput
         label={'金額}
         type={'numeric'}
         value={price}
         onChange={setPrice}
       />
      <div>
         <Button type={'register'} onClick={registerHandler}/>
      </div>
    </div>
    );
}

Method4 さらなる分割

ページコンポーネントをロジック部分とビュー部分に分離する Container/Presenter デザインパターン というものが存在します。やむなく巨大になったページコンポーネントの可読性をよくすることができます。今回は、割愛しますが興味のある方は、試してみてください。

https://zenn.dev/ficilcom/articles/app_router_design_pattern

Method5 地味なテクニック

コメント文

以下の例であれば、前方一致であることが変数名だけで判断できるものがよいでしょう。実装を見てすぐに判断できるのであれば、コメントはない方が良いです。実装を見ても判断できない場合は、コメントがあっても良いかもしれないですが、そもそもの実装・構造がよくない可能性があります。私は、特別な事情がある場合にのみ、コメントするようにしています。

1 行ごとに、コメントをつけるように指導する文化もありますが、大概置いてけぼりになり、誤解を生み、不具合に繋がるので、私は推奨しません。

/app/item/@search/SearchItem.tsx
export const SearchItem = () => {
-  // 商品名の前方一致
-  const [itemNameCriteria, setItemNameCriteria] = useState('');
+  const [itemNamePrefixCriteria, setItemNamePrefixCriteria] = useState('');
  const [categoryCriteria, setCategoryCriteria] = useState('');
  ...
}

こちらも、ぱっと見でわかるので、なくて十分です。

/hooks/item/useRegisterItem
 ...
 export const useRegisterItem = ({
   ...
   const registerHander = () => {
-     // 入力が空の場合は、処理終了
      if(itemName === '') return;
      if(category === '') return;

      if(categoryData.category.length === 0) {
-        // カテゴリが存在しない場合は、処理終了
        return;
      }
      ...
   };
  ...
 }

細かな改行

構造体の要素が 1 行ごとに改行されているのは、レビューの観点で効果を発揮します。この記事でも使用していますが、変更差分が一目でわかるようにすることで、可読性の向上が計れます。可読性はレビュー品質に影響するので、知っておいて損はないでしょう。

あまり、意識しなくても、フォーマッターライブラリはあるので、活用してみてください。
https://prettier.io

ちなみに、ほぼ変更入らないであろうところは、無理に改行する必要はないです。

/hooks/item/useRegisterItem
 type RegisterItemArgs = {
   itemName: string;
   category: string;
   price: number;
 };

 export const useRegisterItem = ({
  itemName,
  category,
  price
 }: RegisterItemArgs) => {
   ...
   const registerHander = () => {
      ...
      mutation({
        variables: {
          itemName;
          category;
          price;
        }
      })
   };

   return {
     registerHandler
   }
 }

まとめ

App Router を使った、仕様変更に強い構成を考えましたが、それなりに、実装コスト、学習コストを伴うものなので、すべてを駆使する必要はないと思っています。重要なのは、その実装が変更されやすいか、そうでないかの見極め です。現在私が参画する案件では、PostGraphile を採用していますが、このライブラリ前提の案件であるため、ページコンポーネントに露出して実装しています。逆に、グルーバルステートは、なかなか定まり兼ねているのもあり、hooks に隠蔽しています。

案件には個性があるので、それぞれにあった方針をエンジニア間で共有し、事故の少ない、最高なプロダクトを目指しましょう。

GitHubで編集を提案
フィシルコム

Discussion