LoginSignup
9
6

More than 1 year has passed since last update.

callback refsでuseEffectを使うのを避ける(Avoiding useEffect with callback refs 和訳)

Last updated at Posted at 2023-01-25

※こちらの記事はTanstack Queryのmaintanierでもあるtkdodoさんの記事Avoiding useEffect with callback refsの和訳になります。著者の許可を得て掲載しています。

注:この記事は、Reactにおけるrefsが何であるかについての基本的な理解を前提としています。

refsは理論的には任意の値を格納できるミュータブルコンテナですが、最もよく使われるのはDOMノードにアクセスするためです。

a-basic-ref.tsx
const ref = React.useRef(null)

return <input ref={ref} defaultValue="Hello world" />

refはビルドインプリミティブの予約プロパティで、Reactがレンダリング後のDOMノードを保存する場所です。コンポーネントがアンマウントされると、NULLに戻されます。

Interacting with refs

ほとんどのインタラクションでは、Reactが自動的に更新を処理してくれるため、基礎となるDOMノードにアクセスする必要はありません。React が自動的に更新を行うからです。refが必要になる良い例が、フォーカス管理です。

Devon Govettによる良いRFCがあり、react-domにFocusManagementを追加することが提案されていますが、今のところReactにはそれを手助けしてくれるものは何もありません。

Focus with an effect

では、今現在、レンダリング後の入力要素にどのようにフォーカスを当てるのでしょうか?(オートフォーカスがあることは知っていますが、これは一例です。もしこれが気になるなら、代わりにノードをアニメーションさせることを想像してみてください)。

私が見たほとんどのコードでは、このようにしてこれを行おうとしています。

focus-an-input.tsx
const ref = React.useRef(null)

React.useEffect(() => {
  ref.current?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />

これはほとんど問題なく、何のルール違反にもなりません。空の依存関係配列は、中で使われているのがrefだけなので、安定していて大丈夫です。リンターは依存配列に追加しても文句を言いませんし、レンダリング中にrefが読み込まれることもありません(これはReactのconcurrent 機能では面倒なことになるかもしれません)。

この効果は「マウント時」に一度だけ実行されます(strict modeでは二度)。その時点でReactはすでにDOMノードでrefをpopulateしているので、それにフォーカスすることができます。

しかし、これは最良の方法ではなく、より高度な状況においてはいくつかの注意点があります。

具体的にはEffectが実行されるときにrefが「満たされている(filled)」ことを前提としています。例えば、レンダリングを遅延させるカスタムコンポーネントに ref を渡したり、他のユーザ操作の後にのみinputを表示させたりして、それが利用できない場合、エフェクトの実行時には ref のコンテンツはまだ null で、フォーカスされるものは何もありません。

custom-form.tsx
function App() {
  const ref = React.useRef(null)

  React.useEffect(() => {
    // 🚨 ref.current is always null when this runs
    ref.current?.focus()
  }, [])

  return <Form ref={ref} />
}

const Form = React.forwardRef((props, ref) => {
  const [show, setShow] = React.useState(false)

  return (
    <form>
      <button type="button" onClick={() => setShow(true)}>
        show
      </button>
      // 🧐 ref is attached to the input, but it's conditionally rendered
      // so it won't be filled when the above effect runs
      {show && <input ref={ref} />}
    </form>
  )
})

以下が起こっていることです。

フォームがレンダリングされます。
input はレンダリングされず、ref は null のままです。
Effectが実行され、何もしません。
inputが表示され、ref は満たされますが、Effectは再実行されないので、フォーカスされません。
問題は、Form の render 関数にEffectが「束縛」されていることです。「Formがマウントされたとき」ではなく、「inputがレンダリングされたとき」にinputにフォーカスしたいのです。

Callback refs

ここで、コールバックrefが登場します。refの型宣言を見たことがある人は、refオブジェクトを渡すだけでなく、関数も渡すことができることがわかると思います。

type Ref<T> = RefCallback<T> | RefObject<T> | null

概念的には、React 要素の ref は、コンポーネントがレンダリングされた後に呼び出される関数であると考えましょう。この関数は、引数として渡されるレンダリングされたDOMノードを取得します。React要素がアンマウントされた場合は、nullでもう一度呼び出されます。

useRef(RefObject)からReact要素にrefを渡すのは、したがって、単なる以下のコードの糖衣構文になります

<input
  ref={(node) => {
    ref.current = node;
  }}
  defaultValue="Hello world"
/>

もう一度、強調しておきます。

全てのref propsはただの関数です

そして、これらの関数はレンダリング後に実行され、そこで副作用を実行するのは全く問題ありません。多分、refがonAfterRenderか何かという名前だったらもっと良かったのでしょう。

この知識があれば、nodeに直接アクセスできるcallback refの中でinputにフォーカスを簡単にあてることができます。

focus-with-callback-ref.tsx
<input
  ref={(node) => {
    node?.focus()
  }}
  defaultValue="Hello world"
/>

まあ、細かいことを言えば、そうなんですけどね…Reactはレンダリングのたびにこの関数を実行します。そのため、頻繁にinputにフォーカスすることに抵抗があるのなら(そうでない可能性もありますが)、Reactにこの関数を実行したいときだけ実行するように指示しなければなりません。

callback-ref-with-use-callback.tsx
const ref = React.useCallback((node) => {
  node?.focus()
}, [])

return <input ref={ref} defaultValue="Hello world" />

これを初期バージョンと比較すると、コードが少なくなり、2つのhookの代わりに1つのhookを使うだけです。また、コールバックは DOM ノードをマウントするコンポーネントではなく、DOM ノードのライフサイクルにバインドされているため、あらゆる状況で動作します。さらに、strict mode(開発環境で実行する場合)では2回実行されることはなく、これは多くの人にとって重要だと思われます。

そして、(古い)Reactドキュメントのこの隠し玉に示されているように、あらゆる種類の副作用を実行するためにそれを使用することができます、例えば、その中でsetStateを呼び出します。この例は実際にかなり良いので、ここに置いておきます。

measure-a-dom-node.tsx
function MeasureExample() {
  const [height, setHeight] = React.useState(0)

  const measuredRef = React.useCallback(node => {
    if (node !== null) {
      setHeight(node.getBoundingClientRect().height)
    }
  }, [])

  return (
    <>
      <h1 ref={measuredRef}>Hello, world</h1>
      <h2>The above header is {Math.round(height)}px tall</h2>
    </>
  )
}

レンダリング後にDOMノードに直接アクセスする必要がある場合は、useRef + useEffectではなく、代わりにcallback refを使うことを検討してください。

9
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
6