(メモ)Reactコンポーネントアンチパターン
(追加)アンチパターン以前にReactのルール違反
- プロップ、ステートのミューテーション
- フックの条件分岐
- フックの繰り返し呼び出し
プロップのアンチパターン
- プロップが多すぎるコンポーネント
- プロップを副作用のトリガーにするコンポーネント
-
オブジェクトをプロップにもつコンポーネント
ステート管理のアンチパターン
- ステートが多すぎるコンポーネント
- 算出できる値をステートにもつコンポーネント
- 頻繁に変化するステートをコンテクストで渡すコンポーネント
- (追加)子供にステートを直接変更させるコンポーネント
- (追加)現在の値をもとに次の値を作るとき、関数を使わない
(追加)どこかでフォームバリデーションの具体例を入れたい
- あらゆる入力を受け付け、バリデーション違反の通知だけする
- バリデーション違反にならない入力だけ受け付ける
- バリデーション違反にならない入力だけ受け付け、さらに表示形式を変える(郵便番号や通貨としての表示など)
頻繁に変化するステートをコンテクストで渡すコンポーネント
頻繁に変化するステートとは
- テキスト入力
- 即時関数
- オブジェクト
即時関数を渡してしまうパターン
function ParentFoo() {
return (
<Provider value={(newFoo) => setFoo(newFoo)}>
<ChildBar />
</Provider>
)
}
毎回新しい関数参照が作られるので、レンダリングのたびに配下のコンポーネントもレンダリングされる。
でもこの場合、ParentFooが再レンダリングされたらChildBarもどうせレンダリングされるので、たとえばuseCallbackするなどで関数参照の同一性を保ったところで、意味がない。
意味があるならこういうパターン
function ParentFoo({ children }) {
return (
<Provider value={...}>
{children}
</Provider>
)
}
ParentFooのレンダリングとchildrenのレンダリングが切り離されているとき。
<ParentFoo>
<ChildBar />
</ParentFoo>
リアルワールドだと具体的には何か?
「子供にステートを直接変更させるコンポーネント」のToastの例。
もう一つシンプルな例はないか?
オブジェクトを渡してしまうパターン。
オレオレコンテクスト状態ストア。
function StoreProvider() {
const [state, setState] = useState({})
return (
<StoreProviderContext.Provider
value={{
state,
setState,
}}
>
{children}
</StoreProviderContext.Provider>
)
}
function App() {
return (
<StoreProvider>
<Header />
<Body />
<Footer />
</StoreProvider>
)
}
具体例として「ライト/ダークテーマの切り替え」とかにしておくか?
いや、テーマは全部のコンポーネントに影響があるから、影響範囲を限定する意味が薄く感じる。
APIレスポンスの保持もなあ、そうバンバカ起きることじゃないし。
やはりトーストメッセージなどの、「読み手は少ないが書き手は多い」ステートが適しているな。
コンテクストにまつわる問題は全部そうか。
具体例 | 読み手 | 書き手 |
---|---|---|
カラーテーマ | 多い | 少ない |
言語設定 | 多い | 少ない |
API レスポンス | 多い | 少ない |
認証情報 | 多い | 少ない |
トーストメッセージ | 少ない | 多い |
ダイアログメッセージ | 少ない | 多い |
ローディング | 少ない | 多い |
子供にステートを直接変更させるコンポーネント
function MyPage() {
const [messages, setMessages] = useState([])
return (
<div>
<Toast messages={messages} setMessages={setMessages} />
<Foo setMessages={setMessages} />
</div>
)
}
密結合になって、MyPageとFooを分けた意味がない。
できれば、子供にはイベントの通知だけしてほしい。
Toastコンポーネントを使うために、useStateの書き方にもいちいち注意しなくてはならない。
型アノテーションを毎回書くなど。
Toastがmessages配列の管理をするのはまだいいが、Fooも配列の管理をやっていかないといけない。
間違って配列を空にしてしまうことが起きないわけではない。
作戦1。
潔く手続型で書く。
function MyPage() {
const toast$ = useRef()
return (
<div>
<Toast ref={toast$} />
<Foo
onMessage={(message) => {
toast$.current.notify(message)
}}
/>
</div>
)
}
愚直でわかりやすさがある。
ToastをレンダリングしてもFooのレンダリングは不要。
作戦2。
コンテクストを使って・・・を書こうとしたら、コンテクスト利用のプラクティスを結構知らないといけない構成になった。
これ自体がアンチパターンを生み出す具体例になる。
作戦2具体例。
いろいろ知識が必要で面倒かもしれない。
ただ、ToastとFoo(もしくはほかにnotifyを呼びたいコンポーネント)の距離がいくら離れていても書き味が変わらないので、汎用性がもっとも高いかもしれない。
function MyPage() {
return (
<ToastMessageProvider>
<Toast />
<Foo />
</ToastMessageProvider>
)
}
export function ToastMessageProvider({ children }) {
const [messages, setMessages] = useState([])
// const notify = (message) => {
// setMessages((messages) => [...messages, message])
// }
// こういう関数を作ってToastMessageSetterContext.Providerに渡すのもいいが、
// そうするとSetter利用するだけのコンポーネントも毎回レンダリングされてしまう。
// 関数の参照が毎回変わるため。
// const notify = useCallback((message) => {
// setMessages((messages) => [...messages, message])
// }, [])
// こういうふうにuseCallbackしないといけない。
return (
<ToastMessageValueContext.Provider value={messages}>
<ToastMessageSetterContext.Provider value={setMessages}>{children}</ToastMessageSetterContext.Provider>
</ToastMessageValueContext.Provider>
)
}
export function useNotifyToast() {
const setMessages = useContext(ToastMessageSetterContext)
return (message) => {
setMessages((messages) => [
...messages,
{
id: Math.random().toString(),
message,
},
])
}
}
function Toast() {
const messages = useContext(ToastMessageValueContext)
const setMessages = useContext(ToastMessageSetterContext)
return (
<div>
{messages.map(({ id, message }) => (
<p
key={id}
onClick={() => {
setMessages((messages) => messages.filter((message) => message.id !== id))
}}
>
{message}
</p>
))}
</div>
)
}
function Foo() {
const notify = useNotifyToast()
notify('hello!')
// ...
}
作戦3。
Toastコンポーネントと対になるフックを作る。
function MyPage() {
const [notify, toastProps] = useToast()
return (
<div>
<Toast {...toastProps} />
<Foo
onMessage={(message) => {
notify(message)
}}
/>
</div>
)
}
function useToast() {
const [messages, setMessages] = useState([])
const notify = (message) => {
setMessages((messages) => [
...messages,
{
id: Math.random().toString(),
message,
},
])
}
const props = {
messages,
setMessages,
}
return [notify, props]
}
わかりやすさはあるが、messagesをMyPageのステートとして持つので、Toastをレンダリングすると(正確にはnotifyを呼ぶと)Fooまでレンダリングされてしまう。
プロップが多すぎるコンポーネント
- 単純に、抱えるUIが大きすぎる。
- 種類の異なるものを無理やり共通化しようとして(見た目が似ているという理由だけで)同じコンポーネントに押し込め、複雑化している。
- 算出できる値をわざわざもらっている。
- コンポーネントたちの親玉で、バケツリレーの上流にいる。
単純に、抱えるUIが大きすぎるパターン。
- 検索条件のコンポーネントと、検索結果のコンポーネントが合体したコンポーネント。
- これは分割しても、親がバケツリレー担当になってプロップ数が変わらない結果もありうる。
種類の異なるものを無理やり共通化しようとして(見た目が似ているという理由だけで)同じコンポーネントに押し込め、複雑化しているパターン。
- アカウント一覧のコンポーネントと、検索してヒットしたアカウントだけを表示するコンポーネント。
- でもこれは結構特殊だな。普通は検索してヒットする結果は複数あるので、一覧と同じものを使うのは筋がとおっている。
- 検索結果が1か0か、という特殊ケースだった。
ほかにいい例あるだろうか。
オブジェクトをプロップにもつコンポーネント
いざというときにmemoでレンダリングの最適化ができない。
・・・うーん、まあそこまでアンチでもないな。
memoできないが必ずしも悪いわけじゃないから。
memoしなくても速く動くものは作れる、というかmemoしても速くなるとは限らない。
具体例。
- APIレスポンスをそのまま受け取るコンポーネント。
プロップを副作用のトリガーにするコンポーネント
単純に副作用と宣言的UIの相性が悪い、同期が難しい。
副作用には、たとえばフォーカス、動画プレイヤーの再生・停止などがある。
function Player({ play }: { play: boolean }) {
const video$ = useRef()
useEffect(() => {
if (play) {
video$.current.play()
} else {
video$.current.pause()
}
}, [play])
return <video ref={video$} />
}
video要素側のUIでplayかそうじゃないかが変わったときにプロップを変化させる手段がない。
親コンポーネントに通知はできるが、絶対にplay=falseにしてくれるとも限らない。
(親コンポーネントも自分で作っているならそういう約束をとりつけるのも可能だろうが、本質的には不要な制約を生んでいるだけ)
そういうことするなら、Playerコンポーネントのrefを使ってref.current.play()
を呼ぶのが健全。
というかそもそも、これはプロップかステートかの問題ではなく、Reactが主かDOMが主かという話。
video要素の再生状態はDOMが主で、それを逆転させようと思ったら作り込みが必要。
「再生状態に応じて変化するボタン」を作りたいなら、DOMのイベントをリッスンして、それに従うコンポーネントを作らないといけない。
公式に書いてあった
どこかでフォームバリデーションの具体例を入れたい
第一に考えるべきは「あらゆる入力を受け付け、バリデーション違反の通知だけする」こと。
「バリデーション違反にならない入力だけ受け付ける」のは、ユーザーの意図と結果が乖離するので、戸惑いや不快感を生む。とくにパスワード入力欄では絶対にやってはいけない。パスワードがわからなくなる(実体験)。
ベストなのは、修正してもいいバリデーション違反は勝手に修正してあげること。
とくに、全角・半角の矯正、ハイフン・分かち書きの有無は勝手に修正すべき。
フィールドの意味によっては、勝手な修正はNGとなる。
たとえばパスワードは、同値性にしか意味がないので、全角・半角の矯正に限らずその他あらゆる変更は絶対だめ。
フォームバリデーションだけじゃなく、フォームに起因するステート管理は全般的にいい具体例になるな。
楽観的UI更新の話もある。
APIから受け取った値、もしくはプロップで受け取った値をステート初期値にして、画面で編集した部分とマージして保持したいという場合。
プロップを初期値に使ったあとプロップが変わったらどうするか?が面倒。
useEffectのdepsで値の変更を監視するというのは、本来的な使い方ではないのでアンチパターン。
プロップとは別に空のステートを持って、ステートに値が入っているならそちらの値を表示、そうでないならプロップの値を表示、とすればいい。
props = {
name: "John Doe",
page: 32,
}
state = {
page: 33
}
<input value={state.name ?? props.name} />
<input value={state.age ?? props.age} />
視聴履歴の一覧がAPIから返ってきていて、それの一部をUI操作で削除した。
削除APIのレスポンスがリスト全量を返すわけではないとか、削除処理後にGET APIを再度呼ぶわけじゃないとかの場合、リストからの当該アイテムの削除はクライアント側のロジックで実現する必要がある。
楽観的UIを実現するときも同様。
その場合は、削除したアイテムのID一覧をステートとして保持し、表示のときにフィルターするのが賢い。
deletedItems = ["foo"]
<div>
{items.filter((item) => !deletedItems.includes(item.id)).map(...)}
</div>
アンチパターン具体例。
郵便番号の ___-____
のフォーマット。
Angularだとfilterという機能がある。
Reactは自前で作る。
Bad. いろいろと許せん実装をしてみた。
const App = () => {
const [input, setInput] = useState('');
const [formattedInput, setFormattedInput] = useState('___-____');
return (
<div>
<input
value={formattedInput}
onChange={e => {
const text = e.currentTarget.value;
setFormattedInput(format(text));
setInput(getOnlyNumber(text));
}}
onKeyDown={e => {
if (e.key === 'Backspace') {
const newInput = getOnlyNumber(formattedInput).slice(0, -1);
setFormattedInput(format(newInput));
}
}}
/>
<button
type="button"
onClick={() => {
console.log({ input });
}}
>
送信
</button>
</div>
);
};
function getOnlyNumber(raw: string): string {
return raw.replace(/\D/g, '');
}
function format(raw: string): string {
const onlyNumber = getOnlyNumber(raw);
const frontPart = onlyNumber.slice(0, 3).padEnd(3, '_');
const endPart = onlyNumber.slice(3, 7).padEnd(4, '_');
return `${frontPart}-${endPart}`;
}
onKeyDownも消したけど動きがキモイ
const App = () => {
const [input, setInput] = useState('');
return (
<div>
<input
value={format(input)}
onChange={e => {
const text = e.currentTarget.value;
setInput(getOnlyNumber(text));
}}
/>
<button
type="button"
onClick={() => {
console.log({ input });
}}
>
送信
</button>
</div>
);
};
function getOnlyNumber(raw: string): string {
return raw.replace(/\D/g, '');
}
function format(raw: string): string {
const onlyNumber = getOnlyNumber(raw);
const frontPart = onlyNumber.slice(-7, -4).padStart(3, '_');
const endPart = onlyNumber.slice(-4).padStart(4, '_');
return `${frontPart}-${endPart}`;
}
多少マシ
const App = () => {
const [input, setInput] = useState('');
const input$ = useRef<HTMLInputElement>(null);
useEffect(() => {
const inputLength = input.length;
const position = inputLength <= 3 ? inputLength : inputLength + 1;
input$.current?.setSelectionRange(position, position);
});
return (
<div>
<input
autoFocus
ref={input$}
value={format(input)}
onChange={e => {
const text = e.currentTarget.value;
const newInput = getOnlyNumber(text);
setInput(newInput);
}}
/>
<button
type="button"
onClick={() => {
console.log({ input });
}}
>
送信
</button>
</div>
);
};
function getOnlyNumber(raw: string): string {
return raw.replace(/\D/g, '');
}
function format(raw: string): string {
const onlyNumber = getOnlyNumber(raw);
const frontPart = onlyNumber.slice(0, 3).padEnd(3, '_');
const endPart = onlyNumber.slice(3, 7).padEnd(4, '_');
return `${frontPart}-${endPart}`;
}
「多少マシ」をこうすると、動きとしては完璧になった。
const App = () => {
- const [input, setInput] = useState('');
+ const [{ input }, setInput] = useState({ input: '' });
const input$ = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -22,7 +22,7 @@ const App = () => {
const text = e.currentTarget.value;
const newInput = getOnlyNumber(text);
- setInput(newInput);
+ setInput({ input: newInput });
}}
/>
Paginationコンポーネントもステート管理のおもしろい題材になりそう。
ページ式APIと絡むといろいろ考慮点がある。
現在の値をもとに次の値を作るとき、関数を使わない
どっかでアンチパターン解説してたと思ったけど、stinさんだった。
もうこの解説がすべて。