Hello Tech

AutoReserve 等を開発する株式会社ハローのテックブログです。スタートアップの最前線から本質的な価値を届けるための技術を紹介します。

Reactでロジックをhooksにまとめないという選択肢

javascripterです。ハローでは、プロダクトのローンチ前からAutoReserve の開発に関わっています。

突然ですが、Reactを使用する際、コンポネントのロジックや状態が増えてきたとき、みなさんはどうされてるでしょうか。

関数コンポネントでは、一般にcustom hooksとしてまとめて切り出すことが多く行われていると思います。

今回の記事では、useState/useRef + custom hooksという単位で切り出すのではなく、 クロージャを使いロジックや状態をコンポネントの外に持たせるようにリファクタリングすることで、コードの見通しが良くなる、という事例を紹介します。

JavaScriptにおけるクロージャとは、関数が外側のスコープの変数などへの参照を保持できる機能のことです。ここではクロージャとして実装しましたが、同等のことはclassを使っても実装できます。

AutoReserveでの複雑なロジックの例

概要

状態管理やロジックが複雑になる例として、非同期処理があります。

ハローでは、レストラン向けにAutoReserve For RestaurantsというReact Native製のレストラン管理システムを提供しています。アプリには予約台帳の機能があります。

予約台帳とは、レストランが予約を管理するための記録システムで、

  • 来店の日時
  • 来店人数
  • 客の名前
  • 予約する席

などの情報を記録できるようになっています。 ある日の予約状況を一覧して見れるよう、チャート表示のUIを実装しています。

予約台帳チャート表示画面

レストランの営業時間は曜日によって異なる場合があり、また祝日の場合など変則的な営業時間の日もあります。よって、クライアント側で特定の日の営業時間を知りたい場合、日付を指定してバックエンド側から動的にとってくる必要があります。

予約が入るのは基本的に営業開始時間以降なため、デフォルトで営業開始時刻までスクロールさせた方が親切です。ここで、画面をスクロールさせるときの実装を考えてみます。

基本的な実行順序

基本的なスクロールの実行順序はこのようになります。ここに、別の日付に移動した時のキャンセル処理などが入ります。

  1. チャート画面を開く
  2. 開いている日付の営業時間を取得し、取得後にスクロールするコールバックを設定
  3. 営業時間の取得が終わったら、コールバックを実行しスクロールを行う

具体的な実装方法

このようなチャートのコンポネントを用意したとします。

// チャート
const Chart = React.forwardRef(({
  dateString,
  onLoadStart,
  onLoadEnd
}, ref) => {
  // ここでは、コンポネント内でswrを使いAPIからデータを取得することを想定
  // https://swr.vercel.app/
  const { data } = useSWR(`/reservations/by_date?date=${dateString}`)

  // 初回マウント、日付が変わったタイミングでonLoadStart()を呼ぶ
  // データのロードが終わったらonLoadEnd()を呼ぶ

  if (!data) return <Loading />

  return <View ref={ref}>
    { /* ... */ }
  </View>
})

このような場合、ざっくり下記のように書き始めることが多いのではないでしょうか。

function Screen() {
  const ref = useRef()

  // dateのstateの定義やヘッダ実装は省略
  const [loadingState, setLoadingState] = useState('loading')

  const requestScroll = () => {
    // 営業日を非同期で取ってきた後、開始時間にスクロールする
    getBusinessTimesByDate(date).then((data) => {
      // この時点でloadingStateがまだloadingの場合はスクロールできないので
      // キューに入れるなどする必要があるが一旦無視
      ref.current?.scrollTo({
        x: getScrollX(data)
      })
    })
  }
  const onLoadStart = () => {}
  const onLoadEnd = () => {}

  return (
    <Chart
      ref={ref}
      date={date}
      onLoadStart={onLoadStart}
      onLoadEnd={onLoadEnd}
    />
  )
}

ここから、

  • loadingStateはレンダリングのタイミングと関係ないためrefを使う
  • requestScrollの実行は日付が変わったら前の分はキャンセルしなければいけないのでcancel処理を追加
  • コンポネントのアンマウント後、スクロール処理はキャンセル
  • ロジックが複雑になってきたため、custom hooksに切り出す
  • useEffect内で使う関数があるため、stableにするためuseCallbackで囲う
  • Chartのロードが完了するまでスクロールを待機させる

のようなことを考え、実装を進めていくと

function useChartResponder(ref) {
  const loadingStateRef = useRef('loading')
  const cancelRef = useRef(null)
  const scrollPayloadRef = useRef(null)

  const requestScroll = useCallback(() => {
    let canceled = false

    // 前回のスクロールが実行中ならキャンセル
    cancelRef.current?.()
    cancelRef.current = () => {
      canceled = true
    }

    getBusinessTimesByDate(date).then((data) => {
      if (canceled) return
      if (loadingStateRef.current === 'loading') {
        // ロード中であればスクロールを待機させる
        scrollPayloadRef.current = { x: getScrollX(data) }
      } else {
        ref.current?.scrollTo({
          x: getScrollX(data)
        })
      }
    })
  }, [ref])

  const onLoadStart = useCallback(() => {
    loadingStateRef.current = 'loading'
    reset()
  }, [loadingStateRef, reset])

  const onLoadEnd = useCallback(() => {
    loadingStateRef.current = 'loaded'
    if (scrollPayloadRef.current) {
      ref.current?.scrollTo({
        x: getScrollX(data)
      })
      scrollPayloadRef.current = null
    }
  }, [ref])

  // unmount時に呼ぶ
  const reset = useCallback(() => {
    cancelRef.current?.()
    cancelRef.current = null
    scrollPayloadRef.current = null
  }, [cancelRef])

  return useMemo(() => {
    return {
      requestScroll,
      onLoadStart,
      onLoadEnd,
    }
  }, [onLoadEnd, onLoadStart, requestScroll])
}

のように切り出すことになるかもしれません。 しかし、このように実装していくと、複数のrefが存在し、それぞれをcurrentで参照しないといけなかったり、useCallbackが増えたり、だんだんと見通しが悪くなっていきます。

状態をhooksの外に出す

そこで、hooksに状態を持たせるのではなく、クロージャを利用し普通の関数に処理をまとめてみると、こうなります。

function createChartResponder(ref) {
  let loadingState = 'loading'
  let cancel = null
  let scrollPayload = null

  const requestScroll = () => {
    let canceled = false
    cancel?.()
    cancel = () => {
      canceled = true
    }
    getBusinessTimesByDate(date).then((data) => {
      if (canceled) return
      if (loadingState === 'loading') {
        scrollPayload = {
          x: getScrollX(data)
        }
      } else {
        ref.current?.scrollTo({
          x: getScrollX(data)
        })
      }
    })
  }

  const onLoadStart = () => {
    loadingState = 'loading'
    reset()
  }

  const onLoadEnd = () => {
    loadingState = 'loaded'
    if (scrollPayload) {
      ref.current?.scrollTo({
        x: getScrollX(data)
      })
      scrollPayload = null
    }
  }

  const reset = () => {
    cancel?.()
    cancel = null
    scrollPayload = null
  }

  return {
    requestScroll,
    onLoadStart,
    onLoadEnd,
    reset
  }
}

この関数を使用する際は

function Screen() {
  const [chartResponder] = useState(() => createChartResponder())

  useEffect(() => {
    return () => {
      chartResponder.reset()
    }
  }, [chartResponder])

  // ...
}

のように使います。こうすることで、

  • createChartResponder内ではrefを使わず普通の変数を自由に使ってコードを書ける
  • useCallbackも使用する必要がない
  • ロジック自体をReactの外に出せるため、テストが容易になる

といったメリットがあります。コード自体も短くなり、メンテナンス性も向上します。

まとめ

Reactを使用していると、useStateやuseRef、useReducer、useEffectなどを使用しcustom hooksを活用してコードを書く場面が多いと思います。

もちろん、custom hooksで簡単に解決できるものも多くありますが、記事で取り上げたようにプレーンな関数・クロージャにロジックをまとめ、hooksを使ってReactとの繋ぎ込みを行うことも検討すると良いと思います。

ハローでは、単純明快なコードを追求する本物のプログラマを募集しています。

少しでも気になる方は気軽に javascripter にDMでお声がけください!

エンジニア採用情報 | Hello, Inc.