💣

ReactのonClickをpropsに渡す際に小さな問題に躓いた

2023/10/07に公開

Reactで開発してると、みなさん99.999%くらいで onClick を書きますよね。
書かない、書いたことないって人がいたら、どうやってるのか気になるので、こっそりGitHubのコードも添えてDMください。

そのonClickですが、今回 onClick の挙動をラップした形でコンポーネントを作ろうと思った時、地味に「??」ってなった問題があったので雑記しときます。

標準な使い方

いわゆる、onClickはReactであれば、どのDOMにも付与することができる万能なクリック所作です(全てのDOMに付与することが良いか悪いかは置いておいて)

何かを登録をしたいとか、ポップアップやモーダルを開きたいとかの所作を入れたい時に使うものですね。
以下で、単純なclick動作を作りました。

import type { FC } from 'react';

export const Sample: FC = () => {
  const clickHandler = () => {
    console.log('click');
  };
  return <button onClick={clickHandler}>sample</button>;
};

今回の問題となったクリック動作

今回は、UIコンポーネントをラップしたコンポーネントを作って、そこにonClickを渡しつつ、別のpropsの挙動をクリックイベントの中にインターセプトしたい時にハマりました。

文字だけだと何言ってるか分からないと思うので、コードを書きました。

// components/Button.tsx
type Props = Pick<ButtonProps, 'children' | 'onClick'> & {
  clickHookEvent?: () => void;
};

export const Button: FC<Props> = ({ clickHookEvent, ...props }) => {
  const { onClick } = props;
  const clickHandler = (e: MouseEvent<HTMLButtonElement>) => {
    if (onClick) {
      onClick(e);
    }
    if (clickHookEvent) {
      clickHookEvent();
    }
  };

  return <OriginalButton onClick={clickHandler} {...props} />;
};
// pages/buttonPage.tsx
import { Button } from '../components/Button';

const App = () => {
  const clickHandler = () => {
    console.log('click handler');
  };

  const clickHookEvent = () => {
    console.log('click hook event');
  };

  return (
    <Button onClick={clickHandler} clickHookEvent={clickHookEvent}>
      押下
    </Button>
  );
};

export default App;

このように二つの関数を渡し、クリック時に両方を実行するようなコンポーネントを作りました。
最初、上記のように、あるUIライブラリのButtonをラップした形で使おうと思ってました。
当然ライブラリにボタンの型もあったので、必要な分だけ使わせてもらおうと、Pickで選択して使用範囲を絞りました。(この記事のコードは簡略化してるので絞り込みの数も少ないです)
ラップされたコンポーネントはUIライブラリと同じ使い心地のまま、その上でサービス知識であるクリックに付随する処理を行う関数も渡せるようにしたかったのです。
Buttonというかなり広範囲の汎用コンポーネントだからこそ、全てのButtonで行う訳ではないので、このラップを行うことで、処理の複雑化を避けるために必要でした。

しかし、上記のコードでは落とし穴があったのです!!(そんな真剣な顔をするほど大きな問題ではない)

実際上記コードで作ったボタンをクリックしたところ、 click handler しかコンソールに出力されないのです!!
読む人が読めば「そりゃそうだ」っていうことなんですが、当時の僕は業務終盤時の結構疲れ切っていた時の作業だったので原因が掴めず苦しみまくってました。

ここの落とし穴ポイントとしては、コンポーネントをラップするために同じ動作を行わせるのに必要なpropsをオリジナルのライブラリのコンポーネントに展開しているところでした。

展開をpropsの最後で行っているが故に、onClickのpropsが上書きされてしまっていました。

はい、それだけなんです。

解決方法

以下のような対応をすることで解決。

type Props = Pick<ButtonProps, 'children' | 'onClick'> & {
  clickHookEvent?: () => void;
};

export const Button: FC<Props> = ({ clickHookEvent, ...props }) => {
  const { onClick } = props;
  const clickHandler = (e: MouseEvent<HTMLButtonElement>) => {
    if (onClick) {
      onClick(e);
    }
    if (clickHookEvent) {
      clickHookEvent();
    }
  };

-  return <ChakraButton onClick={clickHandler} {...props} />;
+  return <ChakraButton {...props} onClick={clickHandler} />;
};

もしくは、propsからonClickを取り出し、展開時に含まれないようにする。

type Props = Pick<ButtonProps, 'children' | 'onClick'> & {
  clickHookEvent?: () => void;
};

- export const Button: FC<Props> = ({ clickHookEvent, ...props }) => {
+ export const Button: FC<Props> = ({ clickHookEvent, onClick, ...props }) => {
-  const { onClick } = props;
  const clickHandler = (e: MouseEvent<HTMLButtonElement>) => {
    if (onClick) {
      onClick(e);
    }
    if (clickHookEvent) {
      clickHookEvent();
    }
  };

  return <ChakraButton onClick={clickHandler} {...props} />;

サービス知識としてオリジナルに仕込みたい関数にのみ焦点が当たってしまうと、あとは全部展開で渡しちゃえっていう考えになっちゃうかもしれませんが、ここは油断せずに。

まとめ

上記により、展開というのは確かに便利ではあるし、スッキリしたコードに見えるけども
バグの元になりやすいなぁというのを改めて感じました。

やっぱり、省略が起こると、そこのコンテキストを理解していないとなかなかに読みにくいコードが出来上がってしまう。
それは結局、自己満のコード以外の何者でもないので、たとえ冗長に感じたり、一見無駄に見えるようなコードでも、明示的にしておくことが良い面もあるということを常に意識してコードが書ければ良いなぁと思いました。

Discussion