🙋‍♀️

初心者向け!宣言的にコンポーネントを実装するために心がけていること

に公開

はじめに

こんにちは〜 レバテック開発部のせおです。

バックエンドをメインにしてきましたが、最近はフロントエンドで React を書くことも増えてきました。そして「React は宣言的にかけるって聞いてたけど、僕のコードめちゃめちゃ手続きに見えるんじゃが…」みたいなことを思ってました。

最近それを少し脱せたかもと感じたので、得られた気づきについて共有できればと思います。
すでに書けてる人にとっては物足りない話かもしれない!

対象読者

  • 初心者の方!
  • 僕と同じような境遇の、バックエンド書いてたけど最近フロントも触り始めた方!

心がけていること

ロジックとそれにまつわる状態を hooks に閉じる

フロントエンドでは、大きく分けるとコンポーネントと hooks の2つを書くことになると思います。

フロントエンドを書き始めた初期は hooks を気軽に作っていいのかあまりわかっておらず、コンポーネントにたくさんロジックを書いていました。

これが、コンポーネントの実装が 手続きっぽく見える原因 でした。

例えば、下記は複数ページあるフォームの簡単な例です。長いロジックの部分を省いて、ページの管理だけを抽出したと思ってください。

複数ページあるフォームの例
import { useState } from "react";

export default function App() {
  const [currentPage, setCurrentPage] = useState(1);

  // ... 長いロジック

  const handleNextPage = () => {
    setCurrentPage((prevPage) => prevPage + 1);
  }

  const handlePrevPage = () => {
    setCurrentPage((prevPage) => prevPage - 1);
  }

  // ... 長いロジック

  return (
    <div>
      <h1>Sample Page</h1>
      {
        currentPage === 1 ? (
          <PageOne
            onNext={handleNextPage}
          />
        ) : currentPage === 2 ? (
          <PageTwo
            onPrev={handlePrevPage}
            onNext={handleNextPage}
          />
        ) : currentPage === 3 ? (
          <PageThree
            onPrev={handlePrevPage}
          />
        ) : (
          <CompletePage />
        )
      }
    </div>
  )
}

この例では、useState を使ったページ管理をコンポーネント内で実装しています。

ページ管理の部分のみ抽出しているからまだ読めますが、ここに他に管理をするものが加わったり、さらにそれが複雑だったりすると、実装がたいへん長くなって「どれが何を操作しているんだ〜」という感じになります。

このような場合に、ロジックとロジックにまつわる状態をまとめて hooks に切り出すのがいいです。

hooksにページ管理のロジックを閉じた場合
import { useState } from "react";

export default function App() {
  // ページ管理のロジックを閉じたカスタムフック
  const { currentPage, handleNextPage, handlePrevPage } = useFormPage(1);

  // ... 長いロジック

  return (
    <div>
      <h1>Sample Page</h1>
      {
        currentPage === 1 ? (
          <PageOne
            onNext={handleNextPage}
          />
        ) : currentPage === 2 ? (
          <PageTwo
            onPrev={handlePrevPage}
            onNext={handleNextPage}
          />
        ) : currentPage === 3 ? (
          <PageThree
            onPrev={handlePrevPage}
          />
        ) : (
          <CompletePage />
        )
      }
    </div>
  )
}

ひとつの hooks を呼び出すと、ページの状態と先ほど実装していた操作を受け取ることができます。これでコンポーネントの実装がすこし見通しよくなりました。

バックエンドを書いてるとき、例えばドメインや Usecase といった各レイヤーごとで抽象度を揃えて実装できると、設計としてキレイな感じがします。
フロントエンドでも同様に、コンポーネント内では hooks に閉じられたロジックを使って組み立てていくことで、抽象度が揃った見通しのいい実装になるのではないかなと思いました。

加えて、このアプローチによって、コンポーネントでは「進める」「戻る」といった意図を表現するだけで済み、具体的な実装は hooks 内に隠蔽されます。これが宣言的なコードにつながると考えています。

hooks のロジックは操作を限定して公開する

上の話に続き、次は hooks の実装で心がけていることです。

上の例では、ロジックとロジックにまつわる状態をまとめた hooks を実装すると述べました。
ここでの注意点として、useState は格納できる型を絞れると言えど、実際の値に対する制約がなく、同じ型であれば意図しない値も設定できてしまうことがあります。

const [state, setState] = useState<number>(1);

例えば、この setState には number 型であればどんな数値も入れることができます。
このままこれを使ってコンポーネントを実装すると、利用側で細かい if 文などが出てきて、どんどん手続きっぽくなっちゃいます。

なにかの状態を管理する hooks を実装するときは、何でもできる更新関数を公開するのではなく、決まった操作のみできる関数を公開することが、宣言的なコンポーネントの実装に近づくために必要だと思っています。

下記は、上で実装したページ管理をするカスタムフックの例です。

操作を限定して公開する例
import { useState } from 'react'

export function useFormPage(initialPage: number, maxPage: number) {
  const [currentPage, setCurrentPage] = useState(initialPage)

  const handleNextPage = () => {
    setCurrentPage(prev => Math.min(prev + 1, maxPage))
  }
  const handlePrevPage = () => {
    setCurrentPage(prev => Math.max(1, prev - 1))
  }

  return { currentPage, handleNextPage, handlePrevPage }
}

このカスタムフックでは、setCurrentPage に決まった操作を設定して、それを呼び出す関数 handleNextPage, handlePrevPage を公開しています。

hooks にページ管理にまつわるロジックが凝集することで、利用側であるコンポーネントでの実装は公開されている関数を呼び出すだけでよくなり、宣言的になります。

useReducer で数種類の操作を見通しよく実装する

また、「次のページに進む」「前のページに戻る」というふうに操作が数種類に限定できるときは、useState を使うより useReducer を使って Reducer 関数に操作をまとめて実装したほうが見通しがよくなるかもしれません。

さきほど実装したカスタムフックを useReducer を使って実装しなおしてみます。

useReducerでの実装例
import { useReducer } from 'react'

/**
 * 現在のページを管理する状態
 */
interface PageState {
  currentPage: number
}

/**
 * ページ管理のアクション定義
 */
const PageActionType = {
  NEXT: 'NEXT',
  PREV: 'PREV',
  SET: 'SET',
} as const
type PageActionType = (typeof PageActionType)[keyof typeof PageActionType]

type PageAction =
  | { type: Extract<PageActionType, 'NEXT'> }
  | { type: Extract<PageActionType, 'PREV'> }
  | { type: Extract<PageActionType, 'SET'>; payload: number }

/**
 * 最大ページ数をあたえて、アクションごとの操作をする Reducer 関数を生成する
 */
const createPageReducer =
  (maxPage: number): React.Reducer<PageState, PageAction> =>
  (state, action) => {
    switch (action.type) {
      case PageActionType.NEXT:
        return { currentPage: Math.min(state.currentPage + 1, maxPage) }
      case PageActionType.PREV:
        return { currentPage: Math.max(1, state.currentPage - 1) }
      case PageActionType.SET:
        return { currentPage: Math.min(Math.max(1, action.payload), maxPage) }
      default:
        return state
    }
  }

/**
 * ページ管理のためのカスタムフック
 *
 * @param initialPage
 * @param maxPage
 * @returns
 */
export function useFormPage(initialPage: number, maxPage: number) {
  const [page, dispatch] = useReducer(createPageReducer(maxPage), {
    currentPage: initialPage,
  })

  const handleNextPage = () => dispatch({ type: PageActionType.NEXT })
  const handlePrevPage = () => dispatch({ type: PageActionType.PREV })
  const setPage = (page: number) => dispatch({ type: PageActionType.SET, payload: page })

  return {
    currentPage: page.currentPage,
    handleNextPage,
    handlePrevPage,
    setPage,
  }
}

任意のページに遷移できる SET という操作を追加して実装してみました。

ページ管理という単純なもの、かつ操作が 3 種類しかないので、ただ冗長に見えるだけであまり恩恵があるように見えないかもしれませんが、Reducer 関数に switch 文として型安全に操作をまとめることが出来ています。

まとめ

宣言的なコンポーネント実装をするための第一歩として

  • ロジックとそれにまつわる状態を hooks に閉じて、それをコンポーネントで使うようにしよう!
  • hooks からは限定した操作を公開しよう!
  • 公式ドキュメント も参考にしよう)

これを心がけていくと見通しのよく実装できるかも✌
hooks にロジックを意味のある単位で、単一責任な形でうまく抽出できると、再利用性も高いしテストもしやすくなってハッピーです!

ちなみに自前でカスタムフックを実装しなくても、世の中便利なライブラリが多いので探せばいい感じのがあると思うので、それを探すのも手です。

レバテック開発部

Discussion