checkbox を React で書いたことありますか?
ちょっとサンプルを書いてみたので、ご覧ください。
import React, { FC } from 'react';
interface Props {
checked?: boolean;
}
const Checkbox: FC<Props> = ({ checked = false }) => (
<input type="checkbox" checked={checked} />
);
export default Checkbox;
超最低限で書くと、こんな感じになります!
checked
という Props を用意して、それを <input>
に流しています。
流れてくる boolean
によって、 checkbox の check 状態を制御できるようになっているわけです。
外部から <Checkbox checked={true} />
とすれば「check された状態」になりますし、
<Checkbox checked={false} />
とすれば「check されていない状態」になります。
つまり、これはどういうことかというと、、、
どんなに checkbox をクリックされても boolean
の値は変更されないため、true
であれば永遠に check された状態になるということです。
ここが、 React の フォーム系 UI を実装するときの混乱するポイントの一つです。。。
checked
と defaultChecked
checkbox には checked に影響する Props が2つ存在します。
checked
というのは、 チェックされているか否かの状態が boolean
値によって常にコントロールされているときに使う Props です。つまり先ほどの例のような状態です。
値が固定されていたら、どんなにユーザが操作しても値が変わらない状態です。
このコンポーネントのことを、制御されている状態のコンポーネントということで、 Controlled Components と呼びます。
一方、defaultChecked
は、従来の HTML のようにユーザ操作によって、チェックされている状態を変更できる状態にしておく Props です。
一見、defaultChecked
の方が扱いやすいイメージがありますが、 defaultChecked
だと初回の render 時に流された boolean
の値で UI が決定したあと、 defaultChecked
に値を流し直しても UI は変更されません。
なので、 Controlled Components に対して、こちらは Uncontrolled Components と呼びます。
どちらを使ってももちろん良いですし、その時折でどちらを使うかを判断する必要があるとは思いますが、、、
少なくとも、両方を同時に使うことはありません!
「コントロールしないのにコントロールする」ってもう破綻していますもんね。
checked
と defaultChecked
を同時に指定すると、 React も Warning を吐いてくれます。
Warning: Checkbox contains an input of type checkbox with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://fb.me/react-controlled-components
いっぱい英語で書かれていますけど、 but not both
と書かれているので、今回のことを言っている Warning で間違いないでしょう!
(より詳しく Uncontrolled Components について知りたい場合は以下を参考にしてください。)
Uncontrolled Components – React
ならば、片方しか指定できないようにしたい!
やっと本題です!笑
Checkbox
というコンポーネントを使いたいときに、 checked
と defaultChecked
の両方の props を流そうとしたら「型エラー」が出るように型で縛っちゃえばいいと思いませんか?
ひょっとしたら、これは人類史上最大の発明をしてしまったかもしれない。。。
checked
と defaultChecked
という2つの Props がある時点で混乱するのに、これを両方指定すると Warning が出てしまうとなったら、もう混乱に混乱を重ねてしまって大変な騒ぎです!!!
つまり、
-
<Checkbox checked={true} />
は OK -
<Checkbox defaultChecked={true}>
は OK -
<Checkbox checked={true} defaultChecked={true} />
は NG
となればいいわけです。
タイトルの「排他」は少し語弊がありますね。
正確に言うと、**「否定論理積」**の振る舞いをさせたいということです。
そんなことできんのか!?という感じですが、 never
型 というのを使えばいけちゃうそうです。
never
を使って checkbox の型を正しく縛ろう!
まずは、never
は使わずに Checkbox Component を作ってみます。
import React, { ReactNode, FC, ChangeEvent } from 'react';
interface Props {
checked?: boolean;
defaultChecked?: boolean;
onChange?(event: ChangeEvent): void;
chidlren?: ReactNode;
}
const Checkbox: FC<Props> = ({
checked,
defaultChecked,
onChange = () => {},
children
}) => (
<label className="checkbox">
<input
type="checkbox"
checked={checked}
defaultChecked={defaultChecked}
onChange={event => onChange(event)}
className="checkbox__main"
/>
<span className="checkbod__label">{children}</span>
</label>
);
export default Checkbox;
ちゃんと onChange
と children
も渡せるようにしてみました。
こうしておけば、 onChange
で checked
の boolean も変えやすくなりますし、 checkbox の横に文字を入れることもできます。
ただ、この状態で Checkbox
を使うと、、、
const App => () => <Checkbox checked={true} defaultChecked={true} />
利用する側は、上記のように checked
と defaultChecked
のどちらを入れてもなんのエラーもなくコンパイルできてしまいます。
これを never で防いでいきます。
never
を入れた型を作っていく
詳しくは、こちらの記事を読んでいただければと思いますが、 never
という型があり、その記事から引用すると
属する値が存在しない型
とのことです。「無が有る」みたいですね。難しい。。。
難しいのですが、この never
を使うと、そこに何かしらの値が入ると型エラーが起こります。
なんでもです。null
も undefined
もエラーです。
この特性をうまく活用していく形になります。
まずは、 Controlled Components でも Uncontrolled Components でも両方で使う Props の型を定義します。
interface BaseProps {
onChange?(event: ChangeEvent): void;
chidlren?: ReactNode;
}
そして、 Controlled Components 用の Props の型と、 Uncontrolled Components 用の型を用意していきます。
両方とも、共通で使う BaseProps
を extends させておきます。
interface ControlledProps extends BaseProps {
checked?: boolean;
defaultChecked?: never;
}
interface UncontrolledProps extends BaseProps {
checked?: never;
defaultChecked?: boolean;
}
ここで never
型が出てきました。
ControlledProps
では defaultChecked
が never
なので、 defaultChecked
に何かしら指定すると怒られます。
defaultChecked: never
にすると、指定しなかったとしても怒られてしまうのですが、undefined を許容してそれを回避しています。
UncontrolledProps
は、ControlledProps
の逆を書いておきます。
そして、この2つの Props を、、、
type Props = ControlledProps | UncontrolledProps;
Union 型にしてしまいます!!!
Union 型とは、string | number
の場合、どちらでも良いよ〜という型になるということです。
つまり、Props
という型は、ControlledProps
でも UncontrolledProps
でもどちらでも良いよ〜という型になったということです。
すべてのコードをまとめると、以下のようになります。
import React, { ReactNode, FC, ChangeEvent } from 'react';
interface BaseProps {
onChange?(event: ChangeEvent): void;
chidlren?: ReactNode;
}
interface ControlledProps extends BaseProps {
checked?: boolean;
defaultChecked?: never;
}
interface UncontrolledProps extends BaseProps {
checked?: never;
defaultChecked?: boolean;
}
type Props = ControlledProps | UncontrolledProps;
const Checkbox: FC<Props> = ({
checked,
defaultChecked,
onChange = () => {},
children
}) => (
<label className="checkbox">
<input
type="checkbox"
checked={checked}
defaultChecked={defaultChecked}
onChange={event => onChange(event)}
className="checkbox__main"
/>
<span className="checkbod__label">{children}</span>
</label>
);
export default Checkbox;
Union 型として定義した Props
は Checkbox Component の Props として型定義してあげます。
こうしてあげると、なんと「否定論理積」のような振る舞いをしてくれます!!
実際に使ってみる
というわけで、実際に import して、 checked
と defaultChecked
の2つを指定してみましょう。
const App = () => (
<Checkbox checked={true} defaultChecked={true} />
);
こう書いてみると、、、
Type '{ checked: true; defaultChecked: true; }' is not assignable to type '(IntrinsicAttributes & ControlledProps & { children?: ReactNode; }) | (IntrinsicAttributes & UncontrolledProps & { children?: ReactNode; })'.
Type '{ checked: true; defaultChecked: true; }' is not assignable to type 'UncontrolledProps'.
Types of property 'checked' are incompatible.
Type 'true' is not assignable to type 'undefined'.
というエラーが出ました!!!
エラーの中身は複雑になっちゃっていますが、両方指定するとちゃんとエラーが出ることは確認できました!!!
取り急ぎ、やりたいことはできました!
以下のときにはちゃんとエラーは出ないので、大成功というわけですね!
const App = () => (
<>
<Checkbox />
<Checkbox checked={true} />
<Checkbox defaultChecked={true} />
</>
);
まとめとか解説とか謝辞とか
もともとこの記事は Type Guard の記事として書こうとしていたのですが、試しているうちに、ControlledProps
と UncontrolledProps
を Union 型にするだけで今回のやりたいことが満たせてしまったので大変びっくりしました。。笑
最初、記事を書く前にテストで書いていたコードには、
function checkControlledProps(props: Props): props is ControlledProps {
return typeof props.checked === "boolean";
}
という関数が書いてあって、**「 checked に boolean
がセットされていれば ControlledProps
とみなしますよ!という関数を書いて、今回の問題を解決します!」**という記事にする予定でしたw
ただ、
type Props = ControlledProps | UncontrolledProps;
と書いているときに、これってどういう型なんだ?と思い始めて、思い切って Type Guard の関数を削除してみたら、結局同じエラーを吐いたので急遽 Type Guard には言及しない記事にしましたw
Type Guard に関しては以下の記事が大変わかりやすいので、ぜひ読んでみてください!笑
TypeScript の Type Guard を使ってキャストいらず
そして、肝心の、どうして今回うまくできたのか、についてですが、
おそらく Union 型にしたことによって、 checked
に boolean
を流せば自動的に ControlledProps
が採用されて、 defaultChecked
に never | undefined
が採用されたんだろうなと思いました。
わざわざ関数を作って判別しなくても、Union 型のどちらが採用されるべきかは明確になるので、今回のやりたかったことができたのだろうなと思っています。
最後、記事を書いておきながら、あれ?となってしまったので、なにかご指摘があればコメント欄にご記載くださいw
また、今回のこの記事は私一人の力で生み出したわけではなく、以前会社で助っ人としてアサインされた方に教えてもらったものを参考に書いております!
教えてもらえて超感謝しています!
以上です!