🌊

Reactで再利用性を高めた変更検知のカスタムフックを作った

に公開

フォームの初期値から変更があった時だけ「保存」ボタンを有効にしたい、といった要件はよくありますよね。

今回は、そんな値の「変更検知」を簡単に行うためのReactカスタムフックを実装しました。そのコードと、設計で工夫した点をご紹介します。

作成したカスタムフック

早速ですが、完成したコードはこちらです。

useChangeDetection.ts
import { useRef, useState } from 'react'
import { isEqual } from 'es-toolkit'

interface UseChangeDetectionReturn<T> {
  currentValue: T
  handleCurrentValueChange: (newValue: T) => void
  resetInitialValue: (resetValue: T) => void
  isDirty: boolean
}

/**
 * 初期値と現在値を管理し、変更を検知するカスタムフック
 * @param initialValue 初期値
 * @returns
 * - currentValue: 現在の値
 * - handleCurrentValueChange: 現在値を更新する関数
 * - resetInitialValue: 初期値をリセットする関数
 * - isDirty: 変更があるかどうかのboolean値
 */
export function useChangeDetection<T>(initialValue: T): UseChangeDetectionReturn<T> {
  const originalValue = useRef<T>(initialValue)
  const [currentValue, setCurrentValue] = useState<T>(initialValue)

  const resetInitialValue = (resetValue: T) => {
    originalValue.current = resetValue
    setCurrentValue(resetValue)
  }

  const handleCurrentValueChange = (newValue: T) => {
    setCurrentValue(newValue)
  }

  const isDirty = (() => {
    return !isEqual(originalValue.current, currentValue)
  })()

  return {
    currentValue,
    handleCurrentValueChange,
    resetInitialValue,
    isDirty,
  }
}

設計のポイント

  • useRefで初期値を保持: originalValueは、コンポーネントが再レンダリングされても値を保持し続けるuseRefで管理します。これが「変更前の値」を記憶する役割を担います。
  • useStateで現在値を管理: currentValueは、ユーザーの操作などで変更される現在の値です。useStateで管理することで、値の変更がUIに正しく反映されます。

コンポーネントでの使い方

カスタムフックの使い方は簡単で、コンポーネントで以下のようにカスタムフック関数を定義します
次の例では、初期値として文字列を渡しています

component.tsx
import { useChangeDetection } from './useChangeDetection';

const ProfileEditor = () => {
  const {
    currentValue,
    handleCurrentValueChange,
    isDirty,
    resetInitialValue,
  } = useChangeDetection<string>('山田 太郎');

  // 保存処理
  const handleSave = () => {
    // 保存処理後、現在の値を新しい「初期値」としてリセット関数に渡す
    // この例ではcurrentValueを渡しています
    resetInitialValue(currentValue);
  };

  return (
    <div>
      <input
        type="text"
        value={currentValue}
        onChange={(e) => handleCurrentValueChange(e.target.value)}
        // isDirtyフラグに応じて背景色を変更したりすることもできます
        style={{ backgroundColor: isDirty ? 'lightyellow' : 'white' }}
      />
      <button onClick={handleSave} disabled={!isDirty}>
        保存
      </button>
    </div>
  );
};

このように、フックから返されるisDirtyフラグを見るだけで、UI(入力欄の背景色やボタンの活性状態)を簡単に切り替えられます。
コンポーネント側で複雑な比較ロジックを持つ必要がなくなり、「変更があったらUIを変える」が分かりやすい宣言的なコードになりました。

ちなみに

es-toolkitライブラリのisEqualを利用しているので、オブジェクトの変更検知もできるようになっています!
こちらはテスト実装で担保してあります

useChangeDetection.test.ts
  test('オブジェクトの値でも変更が検知される', () => {
    // Arrange
    const initialValue = { name: 'John', age: 30 }
    const { result } = renderHook(() => useChangeDetection(initialValue))

    // Act
    act(() => {
      result.current.handleCurrentValueChange({ name: 'John', age: 31 })
    })

    // Assert
    expect(result.current.currentValue).toEqual({ name: 'John', age: 31 })
    expect(result.current.isDirty).toBe(true)
  })

工夫できたところ

当初、初期値はAPI経由で非同期に取得されるケースが多いと考え、propsとして渡されるinitialValueをuseEffectで監視する実装にしていました。

  useEffect(() => {
    setCurrentValue(initialValue)
    originalValue.current = initialValue
  }, [initialValue])

しかし、このカスタムフックを利用する側がAPIを使っていることを暗黙的な前提としてしまっているため、再利用性の観点で好ましくないという的確なレビューをいただきました。
useEffectを使わないで実装したかったということもあり、相談して具体的な実装の提案もしてもらい修正することができました!

-  useEffect(() => {
-    setCurrentValue(initialValue)
-    originalValue.current = initialValue
-  }, [initialValue])
+  const resetInitialValue = (resetValue: T) => {
+    originalValue.current = resetValue
+    setCurrentValue(resetValue)
+  }

まとめ

今回、値の変更を検知するカスタムフックuseChangeDetectionを実装したことで、多くの学びがありました。
Reactのスキルはまだまだですが、自分でカスタムフックを実装することで、より深くReactの仕組みを理解できたように感じます。

Discussion