三行で!
子コンポーネントで親コンポーネントのアクションを実行したいケースがあると思います。
これが少し面倒で、うまく行かず思ったような分離をできないなんて経験もあるのではないかと。
GPTもろくな文章返してくれないし、解説する記事も少ない気がするので一例として参考にしてみてください。
先に結論
こういうとき
interface Props {
state: boolean;
children: ReactElement;
}
export function Parent({ state, children }: Props): ReactElement {
return (
<>
ParentState: {state}
{children}
</>
);
}
function Before() {
const [parentState, setParentState] = useState(false);
const parentAction = () => {
setParentState(true);
};
return (
<Parent state={parentState}>
<Child onAction={parentAction} />
</Pearent>
);
}
こう書こうぜ!
interface Props {
render: (action: () => void): ReactElement;
}
export function Parent({ render }: Props): ReactElement {
const [state, setState] = useState(false);
const action = () => {
setState(true);
};
return (
<>
ParentState: {state}
{render(action)}
</>
);
}
function After() {
return (
<Pearent
render={(action) => {
<Child onAction={action} />;
}}
/>
);
}
結論で満足できた人は以下読まなくてもいいです。ダラダラ解説するだけなんで。
解説
今回は以下のような動きをするコンポーネントを例に解説していきたいと思います。
コードは以下です。Antd を使用していますが、ノリで読んでください。
import { useState, ReactElement } from "react";
import { Card, Form, Input, Button, Typography, Flex } from "antd";
import { CheckCircleOutlined } from "@ant-design/icons";
const { Item } = Form;
const { Text } = Typography;
function Sample(): ReactElement {
const [isSubmit, submit] = useState(false);
const onFinish = () => {
submit(true);
};
const cardStyle = isSubmit
? {
border: "2px solid #52c41a",
position: "relative",
}
: {};
return (
<Card title="テスト用カード" style={cardStyle}>
<Form onFinish={onFinish}>
<Item label="名前">
<Input />
</Item>
<Button type="primary" htmlType="submit">
送信する
</Button>
</Form>
{isSubmit && (
<Flex
style={{
position: "absolute",
right: 0,
bottom: -30,
}}
>
<CheckCircleOutlined
style={{ marginRight: "4px", color: "#52c41a" }}
/>
<Text type="success">成功</Text>
</Flex>
)}
</Card>
);
}
export default Sample;
さてこのコンポーネントの Form
部分だけ変更されたコンポーネントを作成する必要があるとします。
イメージとしては以下のような感じです。
Card
部分の動きは変わらないため、Card
部分を汎用化させて他でも使えるようにしようと試みるはずです。
多くの場合、以下のような形で一度行き詰まるのではないでしょうか?
import { useState, ReactElement } from "react";
import { Card, Form, Input, Button, Typography, Flex } from "antd";
import { CheckCircleOutlined } from "@ant-design/icons";
const { Item } = Form;
const { Text } = Typography;
interface FormCardProps {
isSubmit: boolean;
children: ReactElement;
}
function FormCard({ isSubmit, children }: FormCardProps): ReactElement {
const cardStyle = isSubmit
? {
border: "2px solid #52c41a",
position: "relative",
}
: {};
return (
<Card title="テスト用カード" style={cardStyle}>
{children}
{isSubmit && (
<Flex
style={{
position: "absolute",
right: 0,
bottom: -30,
}}
>
<CheckCircleOutlined
style={{ marginRight: "4px", color: "#52c41a" }}
/>
<Text type="success">成功</Text>
</Flex>
)}
</Card>
);
}
function Sample(): ReactElement {
const [isSubmit, submit] = useState(false);
const onFinish = () => {
submit(true);
};
return (
<FormCard isSubmit={isSubmit}>
<Form onFinish={onFinish}>
<Item label="勤務先">
<Input />
</Item>
<Item label="部門">
<Input />
</Item>
<Button type="primary" htmlType="submit">
送信する
</Button>
</Form>
</FormCard>
);
}
export default Sample;
このコンポーネントの使い勝手が悪いです。
理由として FormCard
を使用する際に useState の定義も強制されるから。
Sampleコンポーネントが FormCard
の state を管理しており、若干の気持ち悪さを抱えています。
ただ、人によってはこれで完了させる人もいるでしょう。
なぜここで手が止まるのか?
ここから先のコンポーネント分割がうまく行かないのはなぜでしょうか?
理由は簡単です。
このように分割すると子コンポーネントから親コンポーネントの state を操作する方法がなくなるからです。
こうなるとエンジニアは叫ぶのです。
「ボタンさえなければもっと綺麗にできるのに!!」...と
ここから先の分割をしたくてもできず、泣く泣くあの形のままにしてしまいがちです。
おそらくこの手の悩みは経験した人が多いのではないでしょうか?(筆者もそうでした)
解決方法を調べた時に「これじゃない」感が多いのも悩ませる一つの要因な気がします。
こういった状態に陥った際、解決するだけなら方法はいくつかあります。
ダサい方法も含めて紹介したいと思います。
解決方法
達成しなければいけないこと
今回は以下二点に着目して解決方法を提示したいと思います。
- 親コンポーネント自身で state を管理できること
- 子コンポーネントで親コンポーネント の state を変更できること
パターン1: Context API を使用する
おそらく一番最初に思いつく方法ではないでしょうか?
コードにすると以下のようになります。
interface FormCardContextProps {
onSubmit: () => void;
}
const FormCardContext = createContext<FormCardContextProps | undefined>(
undefined,
);
const useFormCard = () => {
const context = useContext(FormCardContext);
if (!context) {
throw new Error("useFormCard must be used within a FormCardProvider");
}
return context;
};
interface FormCardProviderProps {
children: ReactElement;
}
function FormCardProvider({ children }: FormCardProviderProps): ReactElement {
const [isSubmit, submit] = useState(false);
// 省略
const onSubmit = () => {
submit(true);
};
return (
<FormCardContext.Provider value={{ onSubmit }}>
<Card ...>
{children}
{isSubmit && (
// 省略
)}
</Card>
</FormCardContext.Provider>
);
}
function ChildForm(): ReactElement {
const { onSubmit } = useFormCard();
return (
<Form onFinish={onSubmit}>
// 省略
<Button type="primary" htmlType="submit">
送信する
</Button>
</Form>
);
}
function Sample(): ReactElement {
return (
<FormCardProvider>
<ChildForm />
</FormCardProvider>
);
}
export default Sample;
多くの人がこの方法は頭をよぎりながらも却下していると思います。
理由としては
- 会社によっては汎用コンポーネントでの Context API を禁止している
- 今回の内容は Context API を使うようのなコンポーネントではない
- 何よりダサい。これやるぐらいなら前の方がマシ
といった感じでしょうか?筆者も同じ気持ちです。
パターン2: cloneElement を使用する
厳密には「子コンポーネントで親コンポーネント の state を変更できる」わけではないのですが、一つの方法として案内させていただきます。
cloneElement 自体に馴染みがない人もいるかと思いますが、名前の通り Element をコピーする関数です。
詳しい解説は避けますが cloneElement を使用すると children に要素の追加や上書きが可能です。
コードを見ていただいた方がイメージしやすいと思うので書きます。
interface FormProps {
onFinish: () => void;
}
interface FormCardProps {
children: ReactElement<FormProps>; // any でも構わないですが、onFinish 持つコンポーネントのみ対応していることを明示しています
}
function FormCard({ children }: FormCardProps): ReactElement {
const [isSubmit, submit] = useState(false);
const { onFinish } = children.props; // 子コンポーネントの元々の onFinish を取得
// 省略
const onSubmit = () => {
submit(true);
};
return (
<Card ...>
{cloneElement(children, {
onFinish: () => { // 子コンポーネントの onFinish を上書きする
onFinish();
onSubmit(); // 元の動作を実行後 onSubmit を実行する
},
})}
{isSubmit && (
// 省略
)}
</Card>
);
}
function Sample(): ReactElement {
return (
<FormCard>
<Form onFinish={() => {}}> // 今回は特に元の動きはないが、本来はある想定で空の関数を書いています。
<Item label="勤務先">
<Input />
</Item>
<Item label="部門">
<Input />
</Item>
<Button type="primary" htmlType="submit">
送信する
</Button>
</Form>
</FormCard>
);
}
export default Sample;
このコードを見て「?」になった人は安心してください。オススメはしないんで。
理由としては
- 限定的すぎる
- Sample だけを見た場合、なんか知らんけど onFinish の後に何かが起こっている。となる
- そもそも書き馴染みがない
ロマンの枠かなと思っています。
閉じられたコンポーネントで使用する分にはありかもしれない。
パターン3: 結論に書いたやつ
なんだかんだ結論に書いたやつが無難だと思います。
最初に書いた達成しなければいけないことを満たせてかつカロリーオフなコードになると思います。
書き方は以下です。
interface RenderProps {
(onSubmit: () => void): ReactElement;
}
interface FormCardProps {
render: RenderProps; // props名が render 以外でも問題ない。なんなら children でもできる
}
function FormCard({ render }: FormCardProps): ReactElement {
const [isSubmit, submit] = useState(false);
// 省略
const onSubmit = () => {
submit(true);
};
return (
<Card ...>
{render(onSubmit)}
{isSubmit && (
// 省略
)}
</Card>
);
}
function Sample(): ReactElement {
return (
<FormCard
render={(submit) => (
<Form onFinish={() => submit()}>
// 省略
<Button type="primary" htmlType="submit">
送信する
</Button>
</Form>
)}
/>
);
}
export default Sample;
私が思うに、このコードの一番のメリットは React Hook Form や UI ライブラリでよく使われる書き方なこと。
定義したことはなくとも、React をそれなりに触れている人であれば
render={({ state }) => ... }
という使い方はしたこのあるのでなないでしょうか?
注意点
ここまで書いておいてなんですが、これら全てのパターンのどれをとっても積極的に使うことをお勧めしません。
そもそもこんなことコードを書かなくても解決できるケースがあります。
例えば、今回であれば完了通知で外枠が変わるとしました。ですが、普段であれば Modal になることが多いはずです。
Modal であれあそもそもこんな書き方にはならないと思います。
今回の書き方をすることで綺麗になるのであれば使用してください。
でもまずは他に方法はないか?と考えてからにしていただけると嬉しいです。
まとめ
子コンポーネントで親コンポーネントを操作する方法を解説させていただきました。
結論で書いたようなコードが書ければ、不要な useState の定義を強要したりや汎用化の幅が広がるのではないかな?と思います。
用法容量を守って使用していただけると嬉しいです。
よき React ライフを!!