useRefとuseEffectで関数を最適化する
はじめに
不要なレンダリングを避けるため、useRef
を使用する手法は一般的ですが、色々なカスタムフックの実装を参考にしている中で、useRef
を使ってcallback
を保持し最適化をしている実装があり、これuseCallback
の最適化とどう違うんだ?と思ったので色々確認してみました。自分の確認も兼ねて残しておきます。
下記検証で使ったリポジトリです。
useRef、useCallbackのそもそもの目的、動作、使用例の違いを少し整理
useCallback
- 目的:関数のメモ化を行い、不要な再生成を防ぐ。
- 動作:依存配列が変更されたときのみ、新しい関数を生成する。
- 使用例:子コンポーネントにpropsとして渡す関数の最適化に主に使用される。
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
useRef
目的:ミュータブルな値を保持し、再レンダリングしても値が保持される。
動作:currentプロパティを通じて値にアクセスし、値の変更はレンダリングをトリガーしない。
使用例:レンダリングを避け値を参照したい場合や、DOMへの参照に使用される。
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current.focus();
}, []);
以上のように、useRef
とuseCallback
は異なる目的で使用されますが、どちらも最適化に使われます。
特に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];
}
これはタイムアウトを管理するためのカスタムフックです。中身の詳細については本題とはあまり関係がないので触れませんが、見る点としては、useRef
とuseEffect
を使用することで、useCallback
と違い、 fnが変更されるたびにset関数を再作成する必要がなくなります。
const callback = useRef(fn);
useEffect(() => {
callback.current = fn;
}, [fn]);
汎用的なカスタムフックを作成してみる
先ほどの処理を参考に汎用的に使えるカスタムフックを作ってみます。
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を使うことにパフォーマンス的に改善すること自体は間違いなさそうですが、ほとんどの場合はuseCallback
とReact.memo
で事足りそうな気がします。
ここまで書いておいてなんですが、個人的にはuseEffect
でuseRef
の値を更新するという動作は少し遠回りで追いづらい処理な気もするので、そんなに積極的には使うことはなさそうです。