【React】@ebay/nice-modal-reactの型強化的な
こんにちは、フリーランスエンジニアの太田雅昭です。
ReactのDialog事情
ReactでのDialogは、ボタンと一緒にElementも作成して使うのが一般的です。muiなどですね。ただしこれは結構面倒だったりします。イベント駆動で出したいだけなのに、毎回Element生成も書かないといけません。
そこで@ebay/nice-modal-reactを試しました。これはProviderで一元管理し、わざわざElementを都度生成しなくてもDialogが開けるようになっています。これは便利。
しかしデメリットもあります。
@ebay/nice-modal-reactのデメリット
@ebay/nice-modal-reactはとにかく型が弱いです。インプットはPartialなため漏れが生じてDialogの中で落ちたり、アウトプットはunknownとなっています。これでは厳しいです。
型強化版的な
そこで似たような使用感で使えるのを作ってみました。GPT5があるとだいぶ楽に作れますね。
// modal.tsx
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
export type ModalProps<P, R> = P & {
open: boolean;
resolve: (value: R) => void;
reject: (reason?: unknown) => void;
close: () => void;
}
type ModalComponent<P, R> = (props: ModalProps<P, R>) => React.ReactNode;
type StackItem = {
id: number;
comp: ModalComponent<any, any>;
props: any;
resolve: (v: any) => void;
reject: (r?: unknown) => void;
};
type ModalContextValue = {
show: <P, R>(comp: ModalComponent<P, R>, props: P) => Promise<R>;
};
const ModalContext = createContext<ModalContextValue | null>(null);
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [stack, setStack] = useState<StackItem[]>([]);
const show = useCallback(<P, R,>(comp: ModalComponent<P, R>, props: P) => {
return new Promise<R>((resolve, reject) => {
setStack((s) => [
...s,
{ id: Date.now() + Math.random(), comp, props, resolve, reject },
]);
});
}, []);
const value = useMemo<ModalContextValue>(() => ({ show }), [show]);
return (
<ModalContext.Provider value={value}>
{children}
{stack.map((item) => {
const close = () =>
setStack((s) => s.filter((x) => x.id !== item.id));
return (
<item.comp
key={item.id}
{...item.props}
open={true}
resolve={(v: any) => { item.resolve(v); close(); }}
reject={(r?: unknown) => { item.reject(r); close(); }}
close={close}
/>
);
})}
</ModalContext.Provider>
);
}
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModal must be used within ModalProvider");
return ctx;
}
下記のようにして使います。型安全です。
import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
import { ModalProvider, useModal, type ModalProps } from "./modal";
type Input = { title: string; description: string; };
type Output = string;
type MyDialogProps = ModalProps<Input, Output>;
function MyDialog(
{ title, description, open, close, reject, resolve }: MyDialogProps
) {
return (
<Dialog open={open} onClose={close}>
<DialogTitle>{title}</DialogTitle>
<DialogContent>{description}</DialogContent>
<DialogActions>
<Button onClick={() => reject("error from dialog")}>Error</Button>
<Button onClick={() => resolve("ok from dialog")}>Ok</Button>
</DialogActions>
</Dialog>
);
}
export default function App() {
return <ModalProvider>
<Inner />
</ModalProvider>
}
function Inner() {
const { show } = useModal();
const handleClick = async () => {
try {
const result = await show(MyDialog, {
title: 'タイトル',
description: "説明",
});
alert("success: " + result);
} catch (e) {
alert("error: " + e);
}
};
return <Button onClick={handleClick}>Show Dialog</Button>;
}
より安全な
上記のコードでは、resolve引数の推論に引っ張られてpropsが間違えてしまうことがあります。これを解決するために、resolveで引数をやめてみました。以下はresolveで引数を渡さないバージョンです。resolveで渡す代わりにイベント駆動を想定しています。
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type ModalBase = {
open: boolean;
resolve: () => void;
reject: (reason?: unknown) => void;
close: () => void;
}
export type ModalProps<P> = P & ModalBase;
type ModalComponent<P> = (props: ModalProps<P>) => React.ReactNode;
type StackItem = {
id: number;
comp: ModalComponent<any>;
props: any;
resolve: ModalBase['resolve'];
reject: ModalBase['reject'];
};
type ShowFn = <P>(comp: ModalComponent<P>, props: Omit<P, keyof ModalBase>) => Promise<void>;
type ModalContextValue = {
show: ShowFn;
};
const ModalContext = createContext<ModalContextValue | null>(null);
export function ModalProvider({ children }: { children: React.ReactNode }) {
const [stack, setStack] = useState<StackItem[]>([]);
const show = useCallback(<P,>(comp: ModalComponent<P>, props: Omit<P, keyof ModalBase>) => {
return new Promise<void>((resolve, reject) => {
setStack((s) => [
...s,
{ id: Date.now() + Math.random(), comp, props, resolve, reject },
]);
});
}, []);
const value = useMemo<ModalContextValue>(() => ({ show }), [show]);
return (
<ModalContext.Provider value={value}>
{children}
{stack.map((item) => {
const close = () =>
setStack((s) => s.filter((x) => x.id !== item.id));
return (
<item.comp
key={item.id}
{...item.props}
open={true}
resolve={() => { item.resolve(); close(); }}
reject={(r?: unknown) => { item.reject(r); close(); }}
close={close}
/>
);
})}
</ModalContext.Provider>
);
}
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("useModal must be used within ModalProvider");
return ctx;
}