Vinyl

useRefとuseEffectで関数を最適化する

はじめに

不要なレンダリングを避けるため、useRefを使用する手法は一般的ですが、色々なカスタムフックの実装を参考にしている中で、useRefを使ってcallbackを保持し最適化をしている実装があり、これuseCallbackの最適化とどう違うんだ?と思ったので色々確認してみました。自分の確認も兼ねて残しておきます。

下記検証で使ったリポジトリです。

GitHub - mdkk11/use-ref-callbackContribute to mdkk11/use-ref-callback development by creating an account on GitHub.favicon icongithub.com

useRef、useCallbackのそもそもの目的、動作、使用例の違いを少し整理

useCallback

  • 目的:関数のメモ化を行い、不要な再生成を防ぐ。
  • 動作:依存配列が変更されたときのみ、新しい関数を生成する。
  • 使用例:子コンポーネントにpropsとして渡す関数の最適化に主に使用される。
const memoizedCallback = useCallback(
 () => {
  doSomething(a, b);
 },
 [a, b],
);

useRef

目的:ミュータブルな値を保持し、再レンダリングしても値が保持される。

動作:currentプロパティを通じて値にアクセスし、値の変更はレンダリングをトリガーしない。

使用例:レンダリングを避け値を参照したい場合や、DOMへの参照に使用される。

const ref = useRef<HTMLInputElement>(null);
 
 useEffect(() => {
  ref.current.focus();
 }, []);

以上のように、useRefuseCallbackは異なる目的で使用されますが、どちらも最適化に使われます。

特にuseRefの値の変更はレンダリングをトリガーしないという点は非常に重要で、レンダリングを起こさず値を保持することができます。

CallbackをuseRefで最適化している具体例

import { useCallback, useEffect, useRef } from 'react';
 
export type UseTimeoutFnReturn = [() => boolean | null, () => void, () => void];
 
export default function useTimeoutFn(fn: Function, ms: number = 0): UseTimeoutFnReturn {
  const ready = useRef<boolean | null>(false);
  const timeout = useRef<ReturnType<typeof setTimeout>>();
  const callback = useRef(fn);
  const isReady = useCallback(() => ready.current, []);
 
  const set = useCallback(() => {
    ready.current = false;
    timeout.current && clearTimeout(timeout.current);
    timeout.current = setTimeout(() => {
      ready.current = true;
      callback.current();
    }, ms);
  }, [ms]);
 
  const clear = useCallback(() => {
    ready.current = null;
    timeout.current && clearTimeout(timeout.current);
  }, []);
 
  useEffect(() => {
    callback.current = fn;
  }, [fn]);
 
  useEffect(() => {
    set();
    return clear;
  }, [ms]);
 
  return [isReady, clear, set];
}

これはタイムアウトを管理するためのカスタムフックです。中身の詳細については本題とはあまり関係がないので触れませんが、見る点としては、useRefuseEffectを使用することで、useCallbackと違い、 fnが変更されるたびにset関数を再作成する必要がなくなります。

const callback = useRef(fn);
 
  useEffect(() => {
    callback.current = fn;
  }, [fn]);

汎用的なカスタムフックを作成してみる

先ほどの処理を参考に汎用的に使えるカスタムフックを作ってみます。

(実際に自分で作成した後に、mantine/packages/@mantine/hooks/src/use-callback-ref/use-callback-ref.ts at master · mantinedev/mantineA fully featured React components library. Contribute to mantinedev/mantine development by creating an account on GitHub.favicon icongithub.comので差し替えてます。)

import { useEffect, useMemo, useRef } from 'react';
 
export function useCallbackRef<T extends (...args: any[]) => any>(callback: T | undefined): T {
  const callbackRef = useRef(callback);
 
  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
 
  return useMemo(() => {
    const fn = (...args: Parameters<T>): ReturnType<T> => {
      return callbackRef.current?.(...args);
    };
    return fn as T;
  }, []);
}

refの値をuseEffectにより更新し、レンダリングを起こさず常に最新の関数が生成されるようになっています。

また、useMemoと組み合わせることにより一度だけ生成されるようにしています。

ではこの作成したカスタムフックを実際に使ってみます。

下記は一般的なユーザープロフィール画面を想定しています。

export const UserProfile = () => {
  const [user, setUser] = useState<User | null>(null);
  const [isEditing, setIsEditing] = useState(false);
 
  const handleUpdate = useCallback(
    async (name: string, email: string) => {
      if (user) {
        const updatedUser = { ...user, name, email };
        const result = await updateUser(updatedUser);
        setUser(result);
        setIsEditing(false);
      }
    },
    [user]
  );
 
  useEffect(() => {
    fetchUser(1).then(setUser);
  }, []);
 
  const toggleEdit = () => setIsEditing(!isEditing);
 
  if (!user) {
    return <div>Loading...</div>;
  }
 
  return (
    <div>
      <h1>User Profile</h1>
      {isEditing ? (
        <form
          onSubmit={(e) => {
            e.preventDefault();
            const formData = new FormData(e.currentTarget);
            handleUpdate(formData.get('name') as string, formData.get('email') as string);
          }}
        >
          <input name="name" defaultValue={user.name} />
          <input name="email" defaultValue={user.email} />
          <button type="submit">Save</button>
        </form>
      ) : (
        <div>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
          <button onClick={toggleEdit}>Edit</button>
        </div>
      )}
      <Child handleUpdate={handleUpdate} />
    </div>
  );
};

useEffectでマウント時にfetchUser関数でユーザーデータを取得し、useStateで状態を管理しています。

Editボタンをクリックすると編集モードに切り替わり、Saveボタンを押すとonSubmitでフォームデータの更新が走ります。

handleUpdate関数はChildコンポーネントに渡す為、useCallbackでラップしてます。また、ChildコンポーネントはReact Memoでラップしてあります。

レンダリングは以下のようになります。

  • 初回レンダリング次→UserProfileコンポーネント、Childコンポーネントが共にレンダリング
  • Edit ボタン押下時→UserProfileコンポーネントのみレンダリング(handleUpdate関数、Childコンポーネントが共にメモ化されている為)
  • フォーム送信時(saveボタン押下時)→UserProfileコンポーネント、Childコンポーネントが共にレンダリング(handleUpdate関数はuseCallbackにてメモ化されているが、依存配列であるuserが更新された為)

これを先ほど作成したuseCallbackRefで最適化してみます。

const handleUpdate = useCallbackRef(async (name: string, email: string) => {
    if (user) {
      const updatedUser = { ...user, name, email };
      const result = await updateUser(updatedUser);
      setUser(result);
      setIsEditing(false);
    }
  })

この変更により以下のようなレンダリングに最適化されます。

  • 初回レンダリング次→UserProfileコンポーネント、Childコンポーネントが共にレンダリング
  • Edit ボタン押下時→UserProfileコンポーネントのみレンダリング(handleUpdate関数、Childコンポーネントが共にメモ化されている為)

(ここまでは同じ)

  • フォーム送信時(saveボタン押下時)→UserProfileコンポーネントのみレンダリング

上記のようにuseCallbackRefを使うことにより依存配列による関数の再生成を避けつつ、最新の状態の関数が利用することができる為、不要なレンダリングを避けることができます。

実際に使うケースがあるのかと考えると下記が考えられそうです。

複数の依存関係がある場合

useCallbackにの依存配列に含まれる状態が多くなれば多くなる程複雑になるので、パフォーマンスも管理も改善できそうです。(そもそもそんな関数の設計自体...というところはありそうですが)

const [state1, setState1] = useState(initialState1);
const [state2, setState2] = useState(initialState2);
// ... 多数の状態
 
// useCallbackを使用した場合
const handleComplexOperation = useCallback(() => {
// state1, state2, ..., stateNを使用する複雑な操作
}, [state1, state2 /* ... 多数の依存関係 */]);
 
// useCallbackRefを使用した場合
const handleComplexOperation = useCallbackRef(() => {
// state1, state2, ..., stateNを使用する複雑な操作
 });

コンポーネントのライフサイクル全体で一貫性を保ちたい場合

例えば以下のようなwebsocketを通じて、リアルタイムデータを長期間存続する接続や、頻繁に更新される状態を扱う場合に有用そうです。

const RealtimeDataComponent = () => {
  const [messages, setMessages] = useState<string[]>([]);
  const [isConnected, setIsConnected] = useState(false);
 
  // useCallbackを使用した場合
 // isConnected の状態が変わるたびに handleMessage 関数が再作成され、それによって WebSocket 接続の再確立が必要になる可能性がある。
  const handleMessage = useCallback(
    (event: MessageEvent) => {
      const newMessage = JSON.parse(event.data);
      setMessages((prevMessages) => [...prevMessages, newMessage]);
      if (isConnected) {
        console.log('Message received while connected:', newMessage);
      }
    },
    [isConnected]
  );
 
  // useCallbackRefを使用した場合
  const handleMessage = useCallbackRef((event: MessageEvent) => {
    const newMessage = JSON.parse(event.data);
    setMessages((prevMessages) => [...prevMessages, newMessage]);
 
  ...
  });
 
  useEffect(() => {
    const socket = new WebSocket('//example.com/socket');
    socket.onopen = () => {
      console.log('WebSocket connected');
      setIsConnected(true);
    };
    socket.onmessage = handleMessage;
    socket.onclose = () => {
      console.log('WebSocket disconnected');
      setIsConnected(false);
    };
 
    // クリーンアップ
    return () => {
      socket.close();
    };
  }, []);
 
  return (
   ...
  );
};

さいごに

useRefとuseCallbackを使うことにパフォーマンス的に改善すること自体は間違いなさそうですが、ほとんどの場合はuseCallbackReact.memoで事足りそうな気がします。

ここまで書いておいてなんですが、個人的にはuseEffectuseRefの値を更新するという動作は少し遠回りで追いづらい処理な気もするので、そんなに積極的には使うことはなさそうです。