ちょっと話題の記事

React + Material-UIで管理画面を作成してみた

2020.07.26

最近、ReactとMaterial-UIを利用した管理画面を作成する機会がありましたので、Reactアプリの作成からMaterial-UIの導入までの手順をまとめてみました。本記事は前提としてJavaScriptやTypeScriptの知識があるとより分かりやすいと思います。

成果物

次のような管理画面を作成します。

React Material-UI 商品ページ

環境

項目 内容
OS macOS Catalina 10.15.5(19F101)
Node.js 12.13.1
TypeScript 3.7.2
React 16.13.1
Material-UI 4.11.0

Reactアプリを作成

Material-UIで管理画面を作るためのベースとなるReactアプリを作成します。

Create React App

Create React Appで新しいReactアプリを作成します。

npx create-react-app react-material-ui-sample --typescript

プロジェクトのディレクトリへ移動して実行します。

cd react-material-ui-sample
npm start

ブラウザにReactアプリが表示されます。

Reactアプリ

ディレクトリ構成

ディレクトリはあまりネストさせすぎずシンプルな構造にしました。コンポーネントの分け方はAtomic Designを参考にしています。

src/
 ├ components/
 │ └ atoms/        # 原子(個々のパーツ)
 │ └ molecules/    # 分子(原子の集合体)
 │ └ organisms/    # 生体(分子の集合体)
 │ └ templates/    # テンプレート(ページの雛形)
 │ └ pages/        # ページ
 ├ App.tsx
 ├ index.css
 ├ index.tsx
〜〜〜〜〜〜〜〜〜〜〜〜〜

ページを作成

サンプルページとしてsrc/components/pages/HomePage.tsxProductPage.tsxを作成します。

HomePage.tsx

import React from "react";

const HomePage: React.FC = () => {
  return <>トップページ</>;
};

export default HomePage;

ProductPage.tsx

import React from "react";

const ProductPage: React.FC = () => {
  return <>商品ページ</>;
};

export default ProductPage;

ルーティング

指定のパスにアクセスした際、先ほど作成した2つのページが描写されるようにルーティングを実装してみます。

まずはルーティングを実装するためのライブラリをインストールします。

npm install --save react-router-dom
npm install --save-dev @types/react-router-dom

App.tsxを編集してルーティングを実装します。

App.tsx

import React from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

import ProductPage from "./components/pages/ProductPage";
import HomePage from "./components/pages/HomePage";

const App: React.FC = () => {
  return (
    <Router>
      <Switch>
        <Route path="/products" component={ProductPage} exact />
        <Route path="/" component={HomePage} exact />
      </Switch>
    </Router>
  );
};

export default App;

npm startで「トップページ」と表示されれば成功です。またhttp://localhost:3000/productsへアクセスすると「商品ページ」と表示されます。

Material-UIを導入

Reactアプリの準備が出来たので、Material-UIを導入していきます。

Material-UIをインストール

Material-UIをインストールします。

npm install --save @material-ui/core @material-ui/icons

フォントを導入

Material-UIと相性の良いGoogle日本語フォントとフォントアイコンを導入しておきます。public/index.htmlのヘッダーにCDNのURLを追加します。

index.html

<head>
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Noto+Sans+JP&subset=japanese" />
    <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
</head>

テンプレートを作成

src/components/templates/にページのテンプレートを作成します。Material-UIが公開しているテンプレートを参考に実装してみます。

GenericTemplate.tsx

import React from "react";
import clsx from "clsx";
import { createMuiTheme } from "@material-ui/core/styles";
import * as colors from "@material-ui/core/colors";
import { makeStyles, createStyles, Theme } from "@material-ui/core/styles";
import { ThemeProvider } from "@material-ui/styles";
import CssBaseline from "@material-ui/core/CssBaseline";
import Drawer from "@material-ui/core/Drawer";
import Box from "@material-ui/core/Box";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import List from "@material-ui/core/List";
import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import Container from "@material-ui/core/Container";
import { Link } from "react-router-dom";
import MenuIcon from "@material-ui/icons/Menu";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import IconButton from "@material-ui/core/IconButton";
import HomeIcon from "@material-ui/icons/Home";
import ShoppingCartIcon from "@material-ui/icons/ShoppingCart";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";

const drawerWidth = 240;

const theme = createMuiTheme({
  typography: {
    fontFamily: [
      "Noto Sans JP",
      "Lato",
      "游ゴシック Medium",
      "游ゴシック体",
      "Yu Gothic Medium",
      "YuGothic",
      "ヒラギノ角ゴ ProN",
      "Hiragino Kaku Gothic ProN",
      "メイリオ",
      "Meiryo",
      "MS Pゴシック",
      "MS PGothic",
      "sans-serif",
    ].join(","),
  },
  palette: {
    primary: { main: colors.blue[800] }, // テーマの色
  },
});

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      display: "flex",
    },
    toolbar: {
      paddingRight: 24,
    },
    toolbarIcon: {
      display: "flex",
      alignItems: "center",
      justifyContent: "flex-end",
      padding: "0 8px",
      ...theme.mixins.toolbar,
    },
    appBar: {
      zIndex: theme.zIndex.drawer + 1,
      transition: theme.transitions.create(["width", "margin"], {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.leavingScreen,
      }),
    },
    appBarShift: {
      marginLeft: drawerWidth,
      width: `calc(100% - ${drawerWidth}px)`,
      transition: theme.transitions.create(["width", "margin"], {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.enteringScreen,
      }),
    },
    menuButton: {
      marginRight: 36,
    },
    menuButtonHidden: {
      display: "none",
    },
    title: {
      flexGrow: 1,
    },
    pageTitle: {
      marginBottom: theme.spacing(1),
    },
    drawerPaper: {
      position: "relative",
      whiteSpace: "nowrap",
      width: drawerWidth,
      transition: theme.transitions.create("width", {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.enteringScreen,
      }),
    },
    drawerPaperClose: {
      overflowX: "hidden",
      transition: theme.transitions.create("width", {
        easing: theme.transitions.easing.sharp,
        duration: theme.transitions.duration.leavingScreen,
      }),
      width: theme.spacing(7),
      [theme.breakpoints.up("sm")]: {
        width: theme.spacing(9),
      },
    },
    appBarSpacer: theme.mixins.toolbar,
    content: {
      flexGrow: 1,
      height: "100vh",
      overflow: "auto",
    },
    container: {
      paddingTop: theme.spacing(4),
      paddingBottom: theme.spacing(4),
    },
    paper: {
      padding: theme.spacing(2),
      display: "flex",
      overflow: "auto",
      flexDirection: "column",
    },
    link: {
      textDecoration: "none",
      color: theme.palette.text.secondary,
    },
  })
);

const Copyright = () => {
  return (
    <Typography variant="body2" color="textSecondary" align="center">
      {"Copyright © "}
      <Link color="inherit" to="/">
        管理画面
      </Link>{" "}
      {new Date().getFullYear()}
      {"."}
    </Typography>
  );
};

export interface GenericTemplateProps {
  children: React.ReactNode;
  title: string;
}

const GenericTemplate: React.FC<GenericTemplateProps> = ({
  children,
  title,
}) => {
  const classes = useStyles();
  const [open, setOpen] = React.useState(true);
  const handleDrawerOpen = () => {
    setOpen(true);
  };
  const handleDrawerClose = () => {
    setOpen(false);
  };

  return (
    <ThemeProvider theme={theme}>
      <div className={classes.root}>
        <CssBaseline />
        <AppBar
          position="absolute"
          className={clsx(classes.appBar, open && classes.appBarShift)}
        >
          <Toolbar className={classes.toolbar}>
            <IconButton
              edge="start"
              color="inherit"
              aria-label="open drawer"
              onClick={handleDrawerOpen}
              className={clsx(
                classes.menuButton,
                open && classes.menuButtonHidden
              )}
            >
              <MenuIcon />
            </IconButton>
            <Typography
              component="h1"
              variant="h6"
              color="inherit"
              noWrap
              className={classes.title}
            >
              管理画面
            </Typography>
          </Toolbar>
        </AppBar>
        <Drawer
          variant="permanent"
          classes={{
            paper: clsx(classes.drawerPaper, !open && classes.drawerPaperClose),
          }}
          open={open}
        >
          <div className={classes.toolbarIcon}>
            <IconButton onClick={handleDrawerClose}>
              <ChevronLeftIcon />
            </IconButton>
          </div>
          <Divider />
          <List>
            <Link to="/" className={classes.link}>
              <ListItem button>
                <ListItemIcon>
                  <HomeIcon />
                </ListItemIcon>
                <ListItemText primary="トップページ" />
              </ListItem>
            </Link>
            <Link to="/products" className={classes.link}>
              <ListItem button>
                <ListItemIcon>
                  <ShoppingCartIcon />
                </ListItemIcon>
                <ListItemText primary="商品ページ" />
              </ListItem>
            </Link>
          </List>
        </Drawer>
        <main className={classes.content}>
          <div className={classes.appBarSpacer} />
          <Container maxWidth="lg" className={classes.container}>
            <Typography
              component="h2"
              variant="h5"
              color="inherit"
              noWrap
              className={classes.pageTitle}
            >
              {title}
            </Typography>
            {children}
            <Box pt={4}>
              <Copyright />
            </Box>
          </Container>
        </main>
      </div>
    </ThemeProvider>
  );
};

export default GenericTemplate;

※再利用可能なパーツは別ファイルでコンポーネント化しておくと便利かもしれません。

テンプレートを使う

トップページと商品ページにテンプレートを適応します。

HomePage.tsx

import React from "react";
import GenericTemplate from "../templates/GenericTemplate";

const HomePage: React.FC = () => {
  return (
    <GenericTemplate title="トップページ">
      <>トップページ内容</>
    </GenericTemplate>
  );
};

export default HomePage;

ProductPage.tsx

import React from "react";
import GenericTemplate from "../templates/GenericTemplate";

const ProductPage: React.FC = () => {
  return (
    <GenericTemplate title="商品ページ">
      <>商品ページ内容</>
    </GenericTemplate>
  );
};

export default ProductPage;

ブラウザでテンプレートが反映されていることを確認します。

React Material-UI

コンポーネントを使う

Material-UIには様々なコンポーネントが用意されていて、コンポーネントを利用することで効率よく開発することが出来ます。例として商品ページにテーブルを実装してみます。

Tableコンポーネント - Material-UI

ProductPage.tsx

import React from "react";
import GenericTemplate from "../templates/GenericTemplate";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";

const createData = (
  name: string,
  category: string,
  weight: number,
  price: number
) => {
  return { name, category, weight, price };
};

const rows = [
  createData("チョコレート", "お菓子", 100, 120),
  createData("ケーキ", "お菓子", 400, 480),
  createData("りんご", "フルーツ", 500, 360),
  createData("バナナ", "フルーツ", 200, 300),
  createData("みかん", "フルーツ", 250, 180),
];

const useStyles = makeStyles({
  table: {
    minWidth: 650,
  },
});

const ProductPage: React.FC = () => {
  const classes = useStyles();

  return (
    <GenericTemplate title="商品ページ">
      <TableContainer component={Paper}>
        <Table className={classes.table} aria-label="simple table">
          <TableHead>
            <TableRow>
              <TableCell>商品名</TableCell>
              <TableCell align="right">カテゴリー</TableCell>
              <TableCell align="right">重量(g)</TableCell>
              <TableCell align="right">価格(円)</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {rows.map((row) => (
              <TableRow key={row.name}>
                <TableCell component="th" scope="row">
                  {row.name}
                </TableCell>
                <TableCell align="right">{row.category}</TableCell>
                <TableCell align="right">{row.weight}</TableCell>
                <TableCell align="right">{row.price}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
    </GenericTemplate>
  );
};

export default ProductPage;

商品ページにテーブルが作成されていることを確認します。

React Material-UI 商品ページ

まとめ

ReactとMaterial-UIを利用することで、新規のアプリ開発が効率よく出来るようになりました。実際のWEBアプリでは認証機能やAPI疎通なども必要になりますので、それらも後々記事にできればと思います。

おすすめ記事

参考資料