[React Router/TypeScript/Material-UI] モーダルにURLを付与する
React Routerで以下のようなモーダルを用意したいと思っていたところ、ドンピシャの実装例がドキュメントに存在しました。
- モーダルに個別のURLを付与したい。
- 別のタブでURLを指定してアクセスした場合はモーダルではなく通常のページで表示したい。
このサンプルコードをベースに、TypeScriptとMateril-UIを使って実装してみました。
本記事は重要な部分の抜粋のみです。以下のGitHubリポジトリに全コードがあります。
準備
Node.jsとyarnは以下のバージョンを利用しています。
$ node -v
v14.15.4
$ yarn -v
1.22.10
Create React AppでTypeScriptテンプレートを利用したサンプルアプリを作成します。
$ npx create-react-app modal-url-sample --template typescript
React Router, Material-UIをインストールしておきます。
$ yarn add react-router-dom @material-ui/{core,icons}
サンプルアプリの実装
まずはモーダル用のコンポーネントを用意します。Material-UIのCustomized dialogsのサンプルコードを少し加工します。useParams
でURLからパスパラメータであるidを取得して表示内容に利用します(実際のアプリケーションではこのidを使用してAPIをたたいてデータを取得する想定です)。
const SampleModal: React.FC = () => {
const history = useHistory();
const { id } = useParams<{ id: string }>();
const [open, setOpen] = React.useState(true);
const handleClose = () => {
history.goBack();
setOpen(false);
};
return (
<Dialog
onClose={handleClose}
aria-labelledby="customized-dialog-title"
open={open}>
<DialogTitle id="customized-dialog-title" onClose={handleClose}>
Sample Modal Id: {id}
</DialogTitle>
<DialogContent dividers>
<Typography gutterBottom>
{[...Array(50)].map(() => `This is a sample modal ${id}!`)}
</Typography>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleClose} color="primary">
Save changes
</Button>
</DialogActions>
</Dialog>
);
};
export default SampleModal;
モーダルを表示するためのボタンを並べるページも用意します。<Buttun>
コンポーネントのcomponent
にReact RouterのLink
を設定しto
でpathname
に遷移先のパスを設定することで、ボタン押下による画面遷移が実装できます。また、state: { background: location}
は「コンテンツをモーダルとして表示するか、通常のページとして表示するか」の判定に使用されるため、重要な設定値です。
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
const data = [
{ id: 1, title: 'sample modal1', content: 'This is a sample modal 1!' },
{ id: 2, title: 'sample modal2', content: 'This is a sample modal 2!' },
{ id: 3, title: 'sample modal3', content: 'This is a sample modal 3!' },
{ id: 4, title: 'sample modal4', content: 'This is a sample modal 4!' },
];
const Samples: React.FC = () => {
const location = useLocation();
const buttonList = data.map((d) => (
<Button
variant="outlined"
color="primary"
component={Link}
key={d.id}
to={{
pathname: `/samples/${d.id}`,
state: { background: location },
}}>
Modal Button {d.id}
</Button>
));
return (
<>
<Typography variant="h4">Modal Samples</Typography>
{buttonList}
</>
);
};
export default Samples;
次に、通常のページで表示する時用のコンポーネントを用意します。表示する内容はモーダルのタイトル・コンテンツと同様です。
import React from 'react';
import { useParams } from 'react-router-dom';
import Typography from '@material-ui/core/Typography';
const SampleView: React.FC = () => {
const { id } = useParams<{ id: string }>();
return (
<>
<Typography variant="h4">Sample Modal Id: {id}</Typography>
<Typography gutterBottom>
{[...Array(50)].map(() => `This is a sample modal ${id}!`)}
</Typography>
</>
);
};
export default SampleView;
最後に、ルーティングの設定をします。useLocation()
でlocation
を取得し、さらにlocation
からstate.background
を取得します。そして、<Switch>
コンポーネントのlocation
というプロパティに、先ほど取得したlocation
とbackground
を設定します。backgournd
を優先して使用するためにlocation={background || location}
とします。
このlocation
とbackground
がルーティングにどのように影響するかはの説明は後回しにし、一旦これで動作確認します。
import React from 'react';
import * as H from 'history';
import { Route, Switch, useLocation } from 'react-router-dom';
import Top from './pages/Top';
import Samples from './pages/Samples';
import SampleModal from './components/SampleModal';
import SampleView from './components/SampleView';
const App: React.FC = () => {
const location = useLocation<{ background: H.Location }>();
const background = location.state?.background;
return (
<>
<Switch location={background || location}>
<Route exact path="/">
<Top />
</Route>
<Route exact path="/samples">
<Samples />
</Route>
<Route exact path="/samples/:id">
<SampleView />
</Route>
</Switch>
{background && (
<Route exact path="/samples/:id">
<SampleModal />
</Route>
)}
</>
);
};
export default App;
動作確認
/samples
画面にアクセスし、適当なボタンを押下するとモーダルが表示されます。また、このモーダルを表示したときにアドレスバーのURLがモーダル用のURLに変わっています。これは意図通りの挙動です。
次に、このモーダルのURLをコピーして別タブで開きます。すると、モーダルではなく通常のページで上記のモーダルと同様のコンテンツが表示されます。これも意図通りの挙動です。
<Switch>
のlocation
について
- モーダルに個別のURLを付与したい。
- 別のタブでURLを指定してアクセスした場合はモーダルではなく通常のページで表示したい。
上記を実装するための重要な点は、src/App.tsx
の<Switch>
コンポーネントで指定しているlocation
プロパティです。location
プロパティを利用することで、ブラウザのアドレスバーに表示されているURLとは異なるURLを用いてルーティングを行うことができます。
A location object to be used for matching children elements instead of the current history location (usually the current browser URL).
参考: location
/samples
にアクセスしたときのlocation
とbackground
は以下の内容です。
location: {pathname: "/samples", search: "", hash: "", state: undefined, key: "bghv4g"}
background: undefined
/samples/1
でモーダルを表示したときは以下になります。
location: {pathname: "/samples/1", state: { background: {pathname: "/samples", search: "", hash: "", state: undefined, key: "bghv4g"} }, search: "", hash: "", key: "w8evpp"}
background: {pathname: "/samples", search: "", hash: "", state: undefined, key: "bghv4g"}
ルーティングのコードだけ抜粋して再掲します。
モーダル表示時、background
にはアドレスバーに現在表示されているURL(モーダルのURL)のパスではなく、モーダルの表示元ページのURLのパスが設定されているため、<Switch>
内では/samples
がモーダルの背景ページとして描画されます(既に表示されているので画面の変化はない)。
// backgroundが存在する場合はbackgroundを元にルーティングが行われる
<Switch location={background || location}>
<Route exact path="/">
<Top />
</Route>
<Route exact path="/samples">
<Samples />
</Route>
<Route exact path="/samples/:id">
<SampleView />
</Route>
</Switch>
<Switch>
内の判定終了後、さらに<Switch>
外に存在するルーティングも処理されます。background
が存在する時、<SampleModal />
が描画されます。これがモーダルの表示を担っています。
{background && (
<Route exact path="/samples/:id">
<SampleModal />
</Route>
)}
別のタブでURLを指定してアクセスした場合、background
は存在しないためlocation
(アドレスバーに表示されている/sample/1
というパス)を元にルーティングされます。その結果、<SampleView />
のみが描画されモーダルは表示されません。
<Switch location={background || location}>
<Route exact path="/">
<Top />
</Route>
<Route exact path="/samples">
<Samples />
</Route>
// このルーティングにマッチする
<Route exact path="/samples/:id">
<SampleView />
</Route>
</Switch>
おわりに
やりたいことに完全にマッチした実装が見つかって良かった…。<Switch>
におけるlocation
プロパティの挙動を調べるきっかけにもなったので一石二鳥でした。
Discussion