【Next.js】UI/ロジックは分離させよう！

# はじめに
こんにちは、H×Hのセンリツ大好きエンジニアです。（同担OKです😉）
Next.jsのプロジェクトにおいて、 UIとロジックを同一ファイルに書くことがかなり多く見られます。
勿論、同一ファイルとすることのメリットもあるとは思いますが、

#### 責務は出来る限り分けたい！！！🥹

ということで、早速UI/ロジックを分離させましょう。
（こちらの記事を参考にしました。分かりやすい記事をありがとうございます。🙇）

https://zenn.dev/rabbit/articles/af45822a7cd01c

# UI/ロジックの分離
まず、UI/ロジックを切り離すメリットとしては以下のものが考えられます。
- 関心事を分離できるので何をしているか分かりやすい
- テストしやすい
- 変更しやすい

これらを実現するために、分離させていきます。

## ディレクトリ構成
`page.tsx`というファイルを、以下のように分離します。

```
.
├── page.tsx      <- UIを記述
└── page.hooks.ts <- ロジックを記述
```

上記のように、`page.tsx`でUI、`page.hooks.ts`でロジックを管理します。

## UI/ロジックを分離する対象のファイル
以下の記事でも紹介した、無限スクロールを実装した記事一覧ページを分離していきます。

https://qiita.com/Senritsu420/items/11bf54d616b571411820

``` page.tsx
"use client"

import { ArticleProps } from "@/components/molecules/ArticleCard/ArticleCard"
import { CONST, STATUS_CODE } from "@/const"
import { useCallback, useEffect, useRef, useState } from "react"
import { Loading } from '@/components/molecules/Loading'
import { NoArticle } from '@/components/atoms/NoArticle'
import { ArticleList } from '@/components/organisms/ArticleList'
import { Box, Container, Text } from '@chakra-ui/react'

export const Page = () => {
  const [ offset, setOffset ] = useState(1)
  const [ articles, setArticles ] = useState<ArticleProps[]>([])
  const loader = useRef<HTMLDivElement | null>(null)
  const [ isVisible, setIsVisible ] = useState(true)

  const handleObserver = useCallback((entities: IntersectionObserverEntry[]) => {
    const target = entities[0]
    if (target.isIntersecting) {
      setOffset((prev) => prev + 1)
    }
  }, [])

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api${CONST.ARTICLES}?per_page=${offset}`)
      if (response.ok) {
        const result = await response.json()
        switch (result.status) {
          case STATUS_CODE.OK:
            if (result.data.length === 0) {
              setIsVisible(false)
            }
            setArticles((prev) => [...prev, ...result.data])
            break // 成功時の処理が完了したらbreakを忘れずに
          default:
            alert(result.status)
            break
        }
      } else {
        alert(response.statusText);
      }
    }
    fetchData()
  }, [offset])

  useEffect(() => {
    const observer = new IntersectionObserver(handleObserver, {
      root: null,
      rootMargin: "20px",
      threshold: 0.5
    });
    if (loader.current) observer.observe(loader.current);

    return () => {
      observer.disconnect();
    };
  }, [handleObserver]);

  return (
    <>
      <Text fontSize="32px" fontWeight={600} lineHeight={1.8} mt="30px" textAlign="center">
        新着記事一覧
      </Text>
      <Container maxW="container.md" py="5%">
        <ArticleList articles={articles} />
        {!isVisible && articles.length === 0 && <NoArticle />}
        <Box ref={loader} h="1px" mt="19px" />
        {isVisible && <Loading />}
      </Container>
    </>
  )
}

```

簡単に内容を説明すると、
``` page.tsx
const handleObserver = useCallback((entities: IntersectionObserverEntry[]) => {
  const target = entities[0]
  if (target.isIntersecting) {
    setOffset((prev) => prev + 1)
  }
}, [])
```
監視対象のDOM要素がビューポート内に表示された場合にoffsetを増加させて、次のデータをフェッチするためのトリガーにする関数。

``` page.tsx
useEffect(() => {
  const fetchData = async () => {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api${CONST.ARTICLES}?per_page=${offset}`)
    if (response.ok) {
      const result = await response.json()
      switch (result.status) {
        case STATUS_CODE.OK:
          if (result.data.length === 0) {
            setIsVisible(false)
          }
          setArticles((prev) => [...prev, ...result.data])
          break // 成功時の処理が完了したらbreakを忘れずに
        default:
          alert(result.status)
          break
      }
    } else {
      alert(response.statusText);
    }
  }
  fetchData()
}, [offset])
```
`offset`が変更されるたびに記事データを取得する非同期関数を定義し、APIからデータをフェッチして記事の配列を更新する関数

``` page.tsx
useEffect(() => {
  const observer = new IntersectionObserver(handleObserver, {
    root: null,
    rootMargin: "20px",
    threshold: 0.5
  });
  if (loader.current) observer.observe(loader.current);

  return () => {
    observer.disconnect();
  };
}, [handleObserver]);
```

監視対象のDOM要素がビューポートに表示されるタイミングを検出するための設定を行う`useEffect`

## ロジックを分離
このファイルから、ロジックを分離させるとこのようになります。
``` page.hooks.ts
import { ArticleProps } from "@/components/molecules/ArticleCard/ArticleCard"
import { CONST, STATUS_CODE } from "@/const"
import { MutableRefObject, useCallback, useEffect, useRef, useState } from "react"

interface returnValue {
  articles: ArticleProps[]
  loader: MutableRefObject<HTMLDivElement | null>
  isVisible: boolean
}

export const usePageHooks = (): returnValue => {
  const [ offset, setOffset ] = useState(1)
  const [ articles, setArticles ] = useState<ArticleProps[]>([])
  const loader = useRef<HTMLDivElement | null>(null)
  const [ isVisible, setIsVisible ] = useState(true)

  const handleObserver = useCallback((entities: IntersectionObserverEntry[]) => {
    const target = entities[0]
    if (target.isIntersecting) {
      setOffset((prev) => prev + 1)
    }
  }, [])

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api${CONST.ARTICLES}?per_page=${offset}`)
      if (response.ok) {
        const result = await response.json()
        switch (result.status) {
          case STATUS_CODE.OK:
            if (result.data.length === 0) {
              setIsVisible(false)
            }
            setArticles((prev) => [...prev, ...result.data])
            break // 成功時の処理が完了したらbreakを忘れずに
          default:
            alert(result.status)
            break
        }
      } else {
        alert(response.statusText);
      }
    }
    fetchData()
  }, [offset])

  useEffect(() => {
    const observer = new IntersectionObserver(handleObserver, {
      root: null,
      rootMargin: "20px",
      threshold: 0.5
    });
    if (loader.current) observer.observe(loader.current);

    return () => {
      observer.disconnect();
    };
  }, [handleObserver]);

  return { articles, loader, isVisible }
}

```
変更した点を説明します。
``` page.hooks.ts
interface returnValue {
  articles: ArticleProps[]
  loader: MutableRefObject<HTMLDivElement | null>
  isVisible: boolean
}
```
まずは、UIでの表示制御に必要な配列や関数などを`returnValue`として定義します。
返す値は以下のものです。
- `articles`: 取得した記事の配列を管理
- `loader`: DOM要素の参照を保持
- `isVisible`: 無限スクロールが続くか示す

``` page.hooks.ts
export const usePageHooks = (): returnValue => {
.
.
.
  return { articles, loader, isVisible }
}
```

`usePageHooks`の返り値として、先ほど定義した`returnValue`を使用しています。

## UI制御に使うロジックの受け取り

UIを担当する`page.tsx`の中身はこちらです。
``` page.tsx
'use client'

import { ArticleList } from '@/components/organisms/ArticleList'
import { Box, Container, Text } from '@chakra-ui/react'
import { usePageHooks } from './Page.hooks'
import { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'
import { Loading } from '@/components/molecules/Loading'
import { NoArticle } from '@/components/atoms/NoArticle'

export const Page = () => {
  const { articles, loader, isVisible } = usePageHooks()
  return (
    <>
      <Text fontSize="32px" fontWeight={600} lineHeight={1.8} mt="30px" textAlign="center">
        新着記事一覧
      </Text>
      <Container maxW="container.md" py="5%">
        <ArticleList articles={articles} />
        {!isVisible && articles.length === 0 && <NoArticle />}
        <Box ref={loader} h="1px" mt="19px" />
        {isVisible && <Loading />}
      </Container>
    </>
  )
}
```

ここでは、`usePageHooks`からUIで使用するものを受け取っています。
このようにして、UIとロジックを分離させる事ができます！🥴

# おわりに
今回はファイル毎に責務を分けるためにUI/ロジックを分離させてみました！
上記のようにUI/ロジックを分離させる事が出来るので、是非やってみてください！🤩

最後までご覧いただきありがとうございました！
以上、センリツでした。🤓