新React Docs(beta)からリファクタを通して正しいhooksの使い方を学ぶ
はじめに
Reactの新しいドキュメントがbeta版ですが公開されているようです。hooksの正しい使い方やしくみがまとめられていてとても勉強になります。
今回はReact Docs(beta)を読んで正しいhooksの使い方を学び、自分のよろしくないコードをリファクタしていこうと思います。
Reactを勉強したての初心者や雰囲気で書いてる人には学びがあるんじゃないかなと思います。
リファクタするもの
以下のコードをリファクタしていきます。
親のチェックボックスで子のチェックボックスを制御できるようなフォームです。
このコードは正しく動作しますが、いくつかhooksの正しくない使い方が含まれています。
よくない点1:useStateの構造
下記のようにチェックボックスの状態をusersから生成しstateで管理しています。
const [checkStates, setCheckStates] = useState(
users.map((user) => ({ ...user, checked: false }))
);
型構造は下記の通りです。
type CheckStates = {
id: number,
name: string,
checked: boolean
}[]
Avoid duplication in state. When the same data is duplicated between multiple state variables, or within nested objects, it is difficult to keep them in sync. Reduce duplication when you can.
https://beta.reactjs.org/learn/choosing-the-state-structure#principles-for-structuring-state
React Docs(beta)によるとstateは他のstateとの重複を避けるべきとあります。
今回の例だとcheckStates
はusers
の情報と重複しています。
必要なのはチェックしているユーザーのidのみなので素直に
const [checkedItems, setCheckedItems] = useState<number[]>([]);
としてチェックしているuserのIDのみを管理するのが適切です。
よくない点2:useEffectの使い方が適切でない
元のコードではcheckStates
の変更をチェックし、checkStates
の変更を検知して親のチェックボックスの状態を変更しています。とあるstateが変更されたら別のstateを変更させるという処理をuseEffect内で行っています。
useEffect(() => {
if (checkStates.every((c) => c.checked)) {
setIsAllChecked(true);
setIsIndeterminate(false);
} else if (checkStates.some((c) => c.checked)) {
setIsAllChecked(false);
setIsIndeterminate(true);
} else {
setIsAllChecked(false);
setIsIndeterminate(false);
}
}, [checkStates]);
このようなuseEffectの使い方は何も考えずに書くと割としがちだと思います。
しかしこのuseEffectの使い方は適切ではありません。
Don’t rush to add Effects to your components. Keep in mind that Effects are typically used to “step out” of your React code and synchronize with some external system. This includes browser APIs, third-party widgets, network, and so on. If your effect only adjusts some state based on other state, you might not need an Effect.
https://beta.reactjs.org/learn/synchronizing-with-effects#you-might-not-need-an-effect
React Docs(beta)によるとuseEffectはReact外部の副作用と内部を同期させるために使用します。
React外部とは例えばAPIのfetch処理であったり、ReactがカバーしていないブラウザのAPIへのアクセスなどです。
今回の使い方はstateを変更させるという意味では副作用ではあるのですがReact内部での副作用ですので適切ではありません。
useEffect内の処理は画面を描画し終わったタイミングで行われます。useEffect内のsetState
によって再レンダリングがトリガーされるため、無駄なレンダリングが多くなってしまいます。
このuseEffectは以下のようにリファクタできます。
解決策1: ハンドラー関数に含める
親のチェックボックスの状態を変更させている直接の要因は、ユーザーがチェックボックスにチェックを入れたり、外したりしていることです。
そのため、ハンドラー関数内の処理に含めるのが今回だと適切です。
const onChange = (id: number) => {
const next = checkStates.map((checkState) => {
if (checkState.id === id)
return { ...checkState, checked: !checkState.checked };
return checkState;
});
setCheckStates(next);
+ if (next.every((c) => c.checked)) {
+ setIsAllChecked(true);
+ setIsIndeterminate(false);
+ } else if (next.some((c) => c.checked)) {
+ setIsAllChecked(false);
+ setIsIndeterminate(true);
+ } else {
+ setIsAllChecked(false);
+ setIsIndeterminate(false);
+ }
};
ちなみに上のコードでsetState
が連続して書かれているため、レンダリングもsetState
の数だけ行われるように勘違いしそうになりますが、レンダリングは一度しか行われません。
Reactは各setState
を見つけるとキューに入れていきます。ハンドラー内の処理は最後まで実行され、その後キューを使用してまとめてレンダリングが行われます。
useStateの仕組みについては、以下のページが参考になります。setState(count + 1)
とsetState(count => count + 1)
の挙動の違いがuseStateの仕組みをもとに説明されていてとても勉強になります。
解決策2: レンダリング中の処理に含める
React Docsによるとレンダリング中の処理にsetState
を含めることはアリなようです。ただし、コード中にもあるようにこれはbetterな方法です。
下のコードでsetSelection(null)
に差し掛かった時点で、return文を待たずしてレンダリングをスキップし新しいレンダリングを開始するようです。
setSelection(null)
以前の処理が無駄になってしまうためbestではなくbetterなのだと考えています。
ハンドラー内に含めることができない場合の手段として有効だと考えられます。
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
よくない点3:無駄なstate
元のコードにはuseStateが3つ含まれています。
- チェックされたアイテムのstate
- 親のチェックボックスがチェックされているかどうかのstate
- 親のチェックボックスがindeterminateかどうかのstate
const [checkedItems, setCheckedItems] = useState<number[]>([]);
const [isAllChecked, setIsAllChecked] = useState(false);
const [isIndeterminate, setIsIndeterminate] = useState(false);
Avoid redundant state. If you can calculate some information from the component’s props or its existing state variables during rendering, you should not put that information into that component’s state.
https://beta.reactjs.org/learn/choosing-the-state-structure
React Docsによるとstateには他のstateから計算できるものは含めるべきではないとあります。
下記のコードはReact Docsからの引用です。fullName
はfirstName
とlastName
から計算可能なのでstateではなく単なる変数にすべきという例です。
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
- const [fullName, setFullName] = useState('');
+ const fullName = firstName + ' ' + lastName
今回のコードもstateを3つから1つに減らすことができます。
isAllChecked
とisIndeterminate
はcheckedItems
から計算可能なのでstateで管理すべきではありません。
stateを追加したくなったらそのstateは他のstateから計算可能かチェックしてみると良いでしょう。。
const [checkedItems, setCheckedItems] = useState<number[]>([]);
- const [isAllChecked, setIsAllChecked] = useState(false);
- const [isIndeterminate, setIsIndeterminate] = useState(false);
+ const isAllChecked = checkedItems.length === users.length
+ const isIndeterminate = !isAllChecked && checkedItems.length > 0
isAllChecked
とisIndeterminate
はレンダリングごとに再計算されます。もしこの計算がコストのかかる計算ならuseMemo
でラップすると良いでしょう。
const isAllChecked = useMemo(() => checkedItems.length === users.length, [
checkedItems,
]);
上のコードだと再レンダリング時にcheckedItemsに変更がなければ再計算は行わず古い値を使用してくれます。
今回は必要なさそうです。
完成
最終的にコードは下のようになります。無駄なuseEffectとstateが減ってめっちゃすっきりしました。
export default function App() {
const [checkedItems, setCheckedItems] = useState<number[]>([]);
const isAllChecked = checkedItems.length === users.length;
const isIndeterminate = !isAllChecked && checkedItems.length > 0;
const handleAllChenge = () => {
if (isAllChecked) {
setCheckedItems([]);
} else {
setCheckedItems(users.map((u) => u.id));
}
};
const onChange = (id: number) => {
if (checkedItems.includes(id)) {
setCheckedItems(checkedItems.filter((c) => c !== id));
} else {
setCheckedItems([...checkedItems, id]);
}
};
const onSubmit = () => {
alert(checkedItems.join(", "));
};
return (
<div className="App">
<div>
<CheckBox
id="parent"
checked={isAllChecked}
onChange={handleAllChenge}
isIndeterminate={isIndeterminate}
/>
<label htmlFor="parent">全て選択</label>
</div>
<div style={{ marginLeft: 30 }}>
{users.map((user, i) => {
return (
<div>
<CheckBo
id={`checkbox${user.id}`}
checked={checkedItems.includes(user.id)}
onChange={() => onChange(user.id)}
/>
<label htmlFor={`checkbox${user.id}`}>{user.name}</label>
</div>
);
})}
</div>
<button onClick={onSubmit}>Submit</button>
</div>
);
}
動作
まとめ
- useStateの構造はシンプルにする。
- 他のstateから計算できるものはstateにしない。
- useEffectはReact外部との同期以外には使用しない。
他にもReact Docsにはhooksを扱うのに役立つtipsやルールがたくさんあるのでまだ見てない方はぜひ見てみてください。
おわりに
新React Docsはこれを読めば他のReact解説記事や技術書は必要ないんじゃないかというくらい充実しています。
まだbeta版ということもありuseMemoやuseCallbackあたりのパフォーマンスについての記事も追加されそうな雰囲気なので楽しみですね。
この記事が参考になれば幸いです!
Discussion