Help us understand the problem. What is going on with this article?

なぜカスタムフックを作るのか

はじめに

本エントリは React アドベントカレンダー11日目の記事です。
余談ですが Costom Hook をカタカナで書くと途端にかっこ悪くなりますね。

対象

  • 普段は Class Component を使っているが Hooks に興味のある方
  • Hooks を使ったことがある方
  • カスタムフックを知っているが使い慣れていない方

背景

Hooks を使うことによって関数コンポーネントでも状態を持つことができるようになり、ここ1年で React による開発は Class Component から Function Component 中心に移り変わっています。Hooks のおかげで少ない記述量でコンポーネントを作ることができたり、thisbind の煩わしさから脱却できたりして開発者の体験を向上させてきました。

一方で、1つのコンポーネントで多くの Hooks を利用すると段々肥大化、複雑化していくという問題 (Class Component でも近いことが言える) がありますが、これはカスタムフックを作ることによって解決できます。

カスタムフックとは

カスタムフックは「コンポーネントからロジックを抽出して再利用可能な関数」のことです。
本来コンポーネント上にそのまま書く Hooks のロジックを関数に切り分けることができると言えばイメージしやすいかもしれません。

React 公式では次のようなサンプルが紹介されています。

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

なぜカスタムフックを作るのか

ここからはコードを交えながら本題の「なぜカスタムフックを作るのか」について紹介します。

ロジックを明示的にグループ分けするため

1つのコンポーネントに多くの state が含まれていると、どの state がどんな目的で何に利用されているのかひと目でわからないことがあります。この問題は、state や state を利用する関数を利用目的に沿ってグループ分けしてカスタムフックに抽出することによって解決できます。

これは僕が個人で開発した Web アプリケーションで使っているイラスト検索用のカスタムフックを少し改変したものです。

export const useIllustSearch = () => {
  const [hasNext, setHasNext] = useState(true)
  const [isLoading, setIsLoading] = useState(false)
  const [offset, setOffset] = useState(0)
  const [searchedIllusts, setSearchedIllusts] = useState<Illust[]>([])
  const [word, setWord] = useState('')

  const search = useCallback(async (searchWord: string) => {
    setIsLoading(true)
    const { illusts, nextUrl } = await searchIllusts({ word: searchWord })
    setSearchedIllusts(illusts)
    setWord(searchWord)

    if (!nextUrl) {
      setHasNext(false)
      return
    }

    const match = nextUrl.match(/\d+$/)
    if (!match) return
    setOffset(Number(match[0]))
    setIsLoading(false)
  }, [])

  const searchNext = useCallback(async () => {
    const { illusts, nextUrl } = await searchIllusts({ word, offset })
    setSearchedIllusts([...searchedIllusts, ...illusts])

    if (!nextUrl) {
      setHasNext(false)
      return
    }

    const match = nextUrl.match(/\d+$/)
    if (!match) return
    setOffset(Number(match[0]))
    setIsLoading(false)
  }, [offset, searchedIllusts, word])

  return { searchedIllusts, search, searchNext, hasNext, isLoading }
}

このフックには4つのステートと2つの関数が含まれています。これらをすべてコンポーネントに直書きすると、かなり見通しが悪くなることが予想されます。しかし、抽出してカスタムフックにすると次にようにコンポーネントに1行書き足すだけですべてのロジックを使用できるようになります。

const SearchPage = () => {
  // スッキリ✨
  const { searchedIllusts, search, searchNext, hasNext, isLoading } = useIllustSearch()

  // 省略
}

Redux / Context とコンポーネントを繋ぐため

react-redux 7.x からは useDispatchuseSelector といった Hook が導入され、以前までのように Container から connect する必要がなくなりました。また、React Hooks の Context API と useContext を使用すると Redux のように global state を管理できます。

ただし、これらをコンポーネントに直書きすると途端に見通しが悪くなります。そこで、global state を利用するロジックはカスタムフックに抽出してしまいましょう。

export type CounterState = { value: number }

export type CounterAction = { type: 'INCREASE' } | { type: 'DECREASE' }

const initialState: State = { value: 0 }

export const counterReducer = (state = initialState, action: CounterAction): CounterState => {
  switch (action.type) {
    case 'INCREASE':
      return { value: state.value + 1 }
    case 'DECREASE':
      return { value: state.value - 1 }
    default:
      return state
  }
}
export const useCounter = () => {
  const count = useSelector<RootState, CounterState>(({ counter }) => counter.value)
  const dispatch = useDispatch<Dispatch<CounterAction>>()

  const increase = () => {
    dispatch({ type: 'INCREASE' })
  }

  const decrease = () => {
    dispatch({ type: 'DECREASE' })
  }

  return { count, increase, decrease }
}

また、非同期処理を伴う場合はカスタムフックの中で読み込み状態やエラー内容を管理することも選択肢として考えられます。これは Naturalclar さんの You may not need redux-thunk に詳しく書かれています。

僕は状態管理をするときにドメイン単位で分割する re-ducks というデザインパターンに近い手法を取ることが多いのですが、各ドメインに対応したカスタムフックを作っておくとドメイン層とプレゼンテーション層を繋ぐ役割としてわかりやすいのでオススメです。

複数のコンポーネントで使われているロジックを共通化するため

以前まで複数のコンポーネントでロジックを共通化するときは、高層コンポーネント (HOC) を利用していました。しかし、高層コンポーネントを使うとコード複雑になるだけではなく、React Devtools を開いたときに複数のラッパーに覆われていて見づらくなります。

カスタムフックを使えば、高層コンポーネントと同じことをより使いやすく見通しがよい形で実現できます。今回は、React 公式が紹介している高層コンポーネントをカスタムフックに書き換えてみました。

// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
  // ...and returns another component...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... that takes care of the subscription...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}
  const useSubscription = (props, selectData) => {
    const [data, setData] = useState(selectData(DataSource, props))

    const handleChange = () => {
      setData(selectData(DataSource, props))
    }

    useEffect(() => {
      DataSource.addChangeListener(handleChange)
      return () => {
        DataSource.removeChangeListener(handleChange)
      }
    }, [])

    return data
  }

記述量が減っていることが一目瞭然です。もちろんコンポーネントの量が増えるわけでもないので、React Devtools 上で見通しが悪くなることはありません。

おわりに

ここまでカスタムフックを作る3つの理由を紹介しました。まだカスタムフックに慣れていない方が本エントリを読んで、試してみるきっかけになれば嬉しいです。何より任意のユースケースに合わせてキレイに作れると気持ちがいいので、これはいけそうと感じたらぜひ実践してみてください。

間違っている箇所や疑問点があればコメントか僕の Twitter で教えていただけると助かります。
明日は @bebetaro さんが Hooks のテストについて書かれるのでお楽しみに!(偶然ですが作る話からテストの話になる流れいいですね)

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away