Next.js x TypeScript初心者向け利用ガイド(フロントエンド編)-Page 3/3

に公開

Next.js x TypeScript 利用ガイド (フロントエンド 編) - Page 2 / 3 の続きの記事になります。
https://zenn.dev/mofuweb/articles/nextjs-typescript-guide-1-2

このページでフロントエンド編は最後になりますが、 Page 2 の 「8. パーツごとのサンプル画面の作成」 で作成したものを組み合わせたサンプルアプリの紹介になります。
後項で StackBlitz 上に構築した Web ブラウザ上で確認できるサンプルアプリのリンクも掲載しています。

不明点等があれば前のページからお読みください。

※6/12 20:50 図の修正
※6/16 22:30 Next.js の dev 実行時の初回画面表示を高速化するために、AppInitializer.tsx を追加し、layout.tsx に追加しました。
※6/18 Next.js の dev 実行速度の改善、ModalContainer、SnackbarContainer の styled を削除し作り直し + プリロードで高速化

(目次)

(Page 3 / 3)

  1. サンプルアプリの作成
    9-1. 前項のサンプルパーツを 1 画面にまとめたページの作成
    9-2. サンプルアプリの公開 URL (GitHub、StackBlitz で公開)

  2. あとがき


9. サンプルアプリの作成

ここでは前項で作成したサンプルパーツの画面を 1 つの画面で実行したサンプルアプリを紹介します。

9-1. 前項のサンプルパーツを 1 画面にまとめたページの作成

前項では、Web テーブルの画面と、モーダル登録画面をそれぞれ作成しました。
これらを 1 つの画面にまとめて実装してみたいと思います。

合体するにあたって、モーダル登録画面は、新規登録ボタンからはモーダル新規登録画面を表示、Web テーブルの行ダブルクリックした時はモーダル編集画面を行います。
それぞれモーダル画面のタイトル、入力項目、ボタン表示を新規登録、編集で切り替えて表示を行っています。

画面の機能としては、Web テーブルには HTTP リクエストの GET でデータ取得、モーダル画面では POST、PATCH、DELETE のそれぞれを実行できるようにしています。

(利用しているファイル)

用途 ファイルパス 備考
コンポーネント(Pages) /app/users/page.tsx
コンポーネント(Templates) /app/users/template.tsx
コンポーネント(Organisms) /app/users/table.tsx
コンポーネント(Organisms) /app/users/modal-form.tsx
共通:コンポーネント(Organisms) /common/organisms/ModalContainer.tsx ※前項に記載のためコードは省略
共通:共通コンポーネント(Organisms) /common/organisms/SnackbarContentContainer.tsx
共通:共通コンポーネント /common/AppInitializer.tsx ※dev 実行時間の短縮のため追加
共通:Validation 実行 /common/ajvValidate.ts
共通:Sanitize 実行 /common/sanitize.ts
HTTP リクエスト用カスタムフック /hooks/useApiRequest.tsx
HTTP リクエスト関数 /services/createUserService.ts
ユーザー定義型 /types/User.ts

(サンプルアプリページのコンポーネントの構成イメージ)
サンプルアプリページのコンポーネントの構成イメージ

以下コードは長いですが、行っていることはシンプルになるように作成しました。
コードと実行結果を記載します。

TypeScript:/app/users/page.tsx

/app/users/page.tsx
// React
import { JSX } from "react";

// App
import Template from "./template";
// import styles from "./page.module.css";

// Page (Routing)
export default function Page(): JSX.Element {
  /**************************************************
   * return JSX.Element
   *
   **************************************************/
  return (
    <>
      <Template />
    </>
  );
}

TypeScript:/app/users/template.tsx

/app/users/template.tsx
"use client";

// React、MUI
import { JSX, useState, useEffect, useCallback } from "react";
import { Box, Backdrop, CircularProgress, Button } from "@mui/material";

// AG Grid
import type { RowDoubleClickedEvent } from "ag-grid-community";

// App
import { Table } from "./table";
import { ModalForm } from "./modal-form";

// Data、Service
import { useApiRequest } from "@/hooks/common/useApiRequest";
import { User } from "@/types/User";
import { createUserService } from "@/services/createUserService";

// Common
import { SnackbarContainer } from "@/common/organisms/SnackbarContainer";
import { sanitize } from "@/common/utilities/sanitize";
// import { useRenderTimer } from "@/hooks/common/useRenderTimer";

// Template (Layout & stateを持つ場所)
export default function Template(): JSX.Element {
  const PUBLIC_API_URL: string = process.env.NEXT_PUBLIC_API_URL ?? "";
  const API_URL: string = `${PUBLIC_API_URL}/users`;

  /**************************************************
   * 状態 (State)、カスタムフック、共通関数
   *
   **************************************************/

  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<Error | null>(null);
  const [snackbar, setSnackbar] = useState<{ open: boolean; message: string }>({ open: false, message: "" });
  const [isOpenModal, setIsOpenModal] = useState<boolean>(false);
  const [openModalKey, setOpenModalKey] = useState<number>(0);

  const [rowData, setRowData] = useState<User[]>([]);
  const [data, setData] = useState<User | null>(null);

  // Custom hook (API requestの状態の保持、例外処理に利用)
  const { request } = useApiRequest({ setIsLoading, setError });

  // Service (状態を持たない関数)
  const userService = createUserService(API_URL);

  // 描画時間計測用
  // useRenderTimer("users");

  /**************************************************
   * 副作用
   *
   **************************************************/

  // 画面表示時にデータ取得し、Tableに表示
  useEffect(() => {
    fetch();

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // エラーメッセージ表示
  useEffect(() => {
    if (error) {
      setSnackbar({ open: true, message: error.message });
    }
  }, [error]);

  /**************************************************
   * 関数・イベント ※Propsで渡す関数名はhandleから始める
   *
   **************************************************/

  // データ取得
  const fetch = async (): Promise<void> => {
    setRowData([]);

    // useApiRequestのrequestにserviceの関数を渡して実行
    // 必要があれば、get()の引数にパラメータのオブジェクトを指定します
    const response = await request(() => userService.get());

    const users = Array.isArray(response)
      ? response.map((element) => {
          // Sanitize後に型にセット
          return sanitize(element);
        }, [])
      : [];

    if (response) {
      setRowData(users);
    } else {
      setRowData([]);
    }
  };

  // 任意のタイミングでデータ取得し、Tableに表示
  const onDataFetch = async (): Promise<void> => {
    await fetch();
  };

  // TableのRowDoubleClickでModalFormを開く
  const handleRowDoubleClick = useCallback((event: RowDoubleClickedEvent<User>) => {
    // Sanitize後に型にセット
    const user = sanitize(event.data);

    // データを渡してモーダルを開く
    if (user) {
      setData(user);
      setIsOpenModal(true);
    }
  }, []);

  // ModalFormを開く
  const onOpen = (): void => {
    setData(null);
    setOpenModalKey((prev) => prev + 1);
    setIsOpenModal(true);
  };

  // ModalFormからの登録
  const handleSubmit = useCallback(async (formData: User) => {
    // Sanitize後に型にセット
    const user = sanitize(formData);

    // useApiRequestのrequestにserviceの関数を渡して実行
    if (!user.createdAt) {
      const response = await request(() => userService.post(user));

      if (response) {
        setIsOpenModal(false);
        fetch();
      }
    } else {
      const response = await request(() => userService.patch(user));

      if (response) {
        setIsOpenModal(false);
        fetch();
      }
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ModalFormからの削除
  const handleDelete = useCallback(async (formData: User) => {
    // Sanitize後に型にセット
    const user = sanitize(formData);

    // useApiRequestのrequestにserviceの関数を渡して実行
    if (user.createdAt) {
      const response = await request(() => userService.del(user));

      if (response) {
        setIsOpenModal(false);
        fetch();
      }
    }

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // ModalFormを閉じる
  const handleClose = useCallback(() => {
    setData(null);
    setOpenModalKey((prev) => prev + 1);
    setIsOpenModal(false);
  }, []);

  /**************************************************
   * return JSX.Element
   *
   **************************************************/
  return (
    <Box sx={{ display: "flex", flexDirection: "column", height: "100%", gap: "5px" }}>
      <Box sx={{ flex: "0 0 auto" }}>
        <h2>ユーザー一覧</h2>
      </Box>

      {/* Table (organisms) */}
      <Box sx={{ flex: "1 1 auto" }}>
        <Table rowData={rowData} onRowDoubleClick={handleRowDoubleClick} />
      </Box>

      {/* ModalForm (organisms) */}
      {/* keyは再マウント用、propsで受取不可 */}
      <ModalForm key={openModalKey} isOpen={isOpenModal} data={data} onFormSubmit={handleSubmit} onDelete={handleDelete} onClose={handleClose} />

      <Box sx={{ flex: "0 0 auto" }}>
        <Box sx={{ display: "inline-block" }}>
          <Button size="small" variant="contained" color="primary" onClick={() => onDataFetch()}>
            データ再取得
          </Button>
        </Box>
        <Box sx={{ display: "inline-block", marginLeft: "5px" }}>
          <Button size="small" variant="contained" color="primary" onClick={onOpen}>
            新規登録
          </Button>
        </Box>
      </Box>

      {/* Loading */}
      <Backdrop open={isLoading} sx={{ zIndex: (theme) => theme.zIndex.tooltip + 1, backgroundColor: "transparent" }}>
        <CircularProgress color="inherit" />
      </Backdrop>

      {/* Message */}
      <SnackbarContainer open={snackbar.open} message={snackbar.message} onClose={() => setSnackbar({ ...snackbar, open: false })} />
    </Box>
  );
}

TypeScript:/app/users/table.tsx

/app/users/table.tsx
"use client";

// React、MUI
import { JSX } from "react";
// import styles from "./page.module.css";

// AG Grid
import { AgGridReact } from "ag-grid-react";
// import { GridApi } from "ag-grid-community";
import type { ColDef, GridOptions, RowDoubleClickedEvent } from "ag-grid-community";

// AG Grid の ModuleRegistry.registerModules は AppInitializerに移動
// import { ModuleRegistry, ClientSideRowModelModule, RowDragModule } from "ag-grid-community";
// ModuleRegistry.registerModules([ClientSideRowModelModule, RowDragModule]);

// Luxon
import { DateTime } from "luxon";

// Data
import { User } from "@/types/User";

// IProps
interface IProps {
  rowData: User[];
  onRowDoubleClick: (event: RowDoubleClickedEvent<User>) => void;
}

// Organisms
export const Table = (props: IProps): JSX.Element => {
  /**************************************************
   * Props
   *
   **************************************************/

  const { rowData, onRowDoubleClick }: IProps = props;

  // Ag Grid: gridApi参照 (必要時)
  // const gridApi = useRef<GridApi | null>(null);

  /**************************************************
   * AG Grid設定
   *
   **************************************************/

  // Ag Grid: Column定義
  const columnDefs: ColDef[] = [
    { rowDrag: true, field: "RowDrag", headerName: "", /* valueGetter: () => { return ""; }, */ /* editable: false, */ width: 40, pinned: "left" },
    { field: "account", headerName: "アカウント名", /* colId: "account", */ width: 140, pinned: "left" },
    { field: "username", headerName: "ユーザー名", width: 140 },
    { field: "age", headerName: "年齢", width: 70 },
    {
      field: "hobby",
      headerName: "趣味",
      width: 100,
      valueFormatter: (params): string => {
        // リストは実際はbackendや定数から取得
        const hobbies: Record<string, string>[] = [
          { code: "", label: "選択してください" },
          { code: "music", label: "音楽" },
          { code: "sports", label: "スポーツ" },
          { code: "reading", label: "読書" },
          { code: "travel", label: "旅行" }
        ];

        const match = hobbies.find((h) => h.code === params.value);
        return match ? match.label : "";
      }
    },
    {
      field: "applyDate",
      headerName: "適用日",
      width: 120,
      valueFormatter: (params): string => {
        /*
        if (!params.value) return "";
        const datetime = parseISO(params.value);
        return isValid(datetime) ? format(datetime, "yyyy/MM/dd") : "";
        */
        if (!params.value) return "";
        const datetime = DateTime.fromISO(params.value);
        return datetime.isValid ? datetime.toFormat("yyyy/MM/dd") : "";
      }
    },
    { field: "isEnabled", headerName: "有効フラグ", width: 110 },
    { field: "remarks", headerName: "備考", width: 140 },
    { field: "isDeleted", headerName: "IsDeleted", hide: true },
    { field: "sortOrder", headerName: "順序", width: 70 },
    { field: "createdAt", headerName: "CreatedAt", hide: true },
    {
      field: "updatedAt",
      headerName: "更新日時",
      width: 160,
      valueFormatter: (params): string => {
        if (!params.value) return "";
        const datetime = DateTime.fromISO(params.value);
        return datetime.isValid ? datetime.toFormat("yyyy/MM/dd HH:mm:ss") : "";
      }
    },
    { field: "createdBy", headerName: "CreatedBy", hide: true },
    { field: "updatedBy", headerName: "UpdatedBy", hide: true }
  ];

  // Ag Grid: GridOptions
  const gridOptions: GridOptions = {
    columnDefs: columnDefs,
    rowDragManaged: true,
    onRowDoubleClicked: onRowDoubleClick
  };

  /**************************************************
   * return JSX.Element
   *
   **************************************************/
  return (
    <>
      {/* AgGridReactを利用、gridOptions と rowData を渡す */}
      <AgGridReact gridOptions={gridOptions} rowData={rowData} />
    </>
  );
};

TypeScript:/app/users/modal-form.tsx

/app/users/modal-form.tsx
"use client";

// React、MUI
import { JSX, useEffect } from "react";
import { Autocomplete, Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel, TextField, Typography } from "@mui/material";
import ClearIcon from "@mui/icons-material/Clear";
// 他に Select: Select, MenuItemを利用、FormHelperText: エラーメッセージの位置調整などがある

// MUI for DatePicker
import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";

// Luxon
import { DateTime } from "luxon";

// React Hook Form
import { Controller, useForm } from "react-hook-form";

// Data
import { User, userSchema } from "@/types/User";

// Common
import { ModalContainer } from "@/common/organisms/ModalContainer";
import { ajvValidate } from "@/common/utilities/ajvValidate";

// Props (keyは受取不可、propsに含めないように注意)
interface IProps {
  isOpen: boolean;
  data: User | null;
  onFormSubmit: (user: User) => Promise<void>;
  onDelete: (user: User) => Promise<void>;
  onClose: () => void;
}

// Organisms
export const ModalForm = (props: IProps): JSX.Element => {
  /**************************************************
   * Props
   *
   **************************************************/

  const { isOpen, data, onFormSubmit, onDelete, onClose } = props;

  // リストは実際はbackendや定数から取得
  const hobbies: Record<string, string>[] = [
    { code: "music", label: "音楽" },
    { code: "sports", label: "スポーツ" },
    { code: "reading", label: "読書" },
    { code: "travel", label: "旅行" }
  ];

  /**************************************************
   * 状態 (State)
   *
   **************************************************/

  // React Hook Form state
  const {
    control,
    formState: { errors },
    handleSubmit,
    register,
    reset,
    setError
  } = useForm<User>({
    defaultValues: {
      account: "",
      username: "",
      age: 0,
      hobby: "",
      isEnabled: false,
      remarks: "",
      isDeleted: false,
      sortOrder: 0,
      createdAt: "",
      updatedAt: "",
      createdBy: "",
      updatedBy: ""
    }
  });

  /**************************************************
   * 副作用
   *
   **************************************************/

  // 新規登録 or 編集
  useEffect(() => {
    if (!data) {
      reset();
      return;
    }

    const user: User = {
      account: data.account ?? "",
      username: data.username ?? "",
      password: data.password ?? "",
      age: data.age ?? 0,
      hobby: data.hobby ?? "",
      applyDate: data.applyDate ?? "",
      isEnabled: data.isEnabled ?? false,
      remarks: data.remarks ?? "",
      isDeleted: data.isDeleted ?? false,
      sortOrder: data.sortOrder ?? 0,
      createdAt: data.createdAt ?? "",
      updatedAt: data.updatedAt ?? "",
      createdBy: data.createdBy ?? "",
      updatedBy: data.updatedBy ?? ""
    };

    reset(user);
  }, [isOpen, data, reset]);

  /**************************************************
   * 関数・イベント ※Propsで渡す関数名はhandleから始める
   *
   **************************************************/

  // const onChange = (): void => {};

  const onSubmit = async (formData: User): Promise<void> => {
    // dataとformDataをマージ
    // data: 編集時のデータ(キー、日時など)を保持
    // formData: 入力内容で上書き
    // ...スプレッドでマージする場合は型を指定する
    const margeData: User = {
      ...data,
      ...formData
    };

    // Validation (自作ユーティリティ)
    const validationErrors = ajvValidate<User>(margeData, userSchema);

    // エラーがあればReact Hook Formに表示
    if (validationErrors.length) {
      for (const { field, message } of validationErrors) {
        // field as keyof User: User型のプロパティであること
        setError(field as keyof User, { type: "manual", message: message });
      }

      // エラー時は中断
      return;
    }

    const user = margeData;

    // Submit
    onFormSubmit(user);
  };

  /**************************************************
   * return JSX.Element
   *
   **************************************************/
  return (
    <>
      {/* Modal form */}
      <ModalContainer open={isOpen} onClose={onClose}>
        {/* MUI Datepickerの宣言 */}
        <LocalizationProvider dateAdapter={AdapterLuxon}>
          {/* Form */}
          {/* Modal header */}
          <Typography variant="h6" component="h2">
            ユーザーの{!data ? "新規登録" : "編集"}
          </Typography>

          {/* Modal content */}
          <Box>
            {/* account */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                アカウント名<span style={{ marginLeft: 1, color: "red" }}>*</span>
              </InputLabel>
              <Controller
                name="account"
                control={control}
                render={({ field }) => (
                  <TextField
                    size="small"
                    sx={{
                      flexGrow: 1,
                      "& .MuiFormHelperText-root": {
                        whiteSpace: "pre-line"
                      }
                    }}
                    {...field}
                    error={!!errors.account}
                    helperText={errors.account?.message}
                    placeholder="例: test@gmail.com"
                  />
                )}
              />
            </Box>

            {/* username */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                ユーザー名<span style={{ marginLeft: 1, color: "red" }}>*</span>
              </InputLabel>
              <Controller
                name="username"
                control={control}
                render={({ field }) => (
                  <TextField
                    size="small"
                    sx={{
                      flexGrow: 1,
                      "& .MuiFormHelperText-root": {
                        whiteSpace: "pre-line"
                      }
                    }}
                    {...field}
                    error={!!errors.username}
                    helperText={errors.username?.message}
                    placeholder="例: test"
                  />
                )}
              />
            </Box>

            {/* password (パスワード更新はパスワードリマインダーなど専用で実施する必要あり) */}
            {!data ? (
              <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
                <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                  パスワード
                </InputLabel>
                <Controller
                  name="password"
                  control={control}
                  render={({ field }) => (
                    <TextField
                      size="small"
                      sx={{
                        flexGrow: 1,
                        "& .MuiFormHelperText-root": {
                          whiteSpace: "pre-line"
                        }
                      }}
                      {...field}
                      error={!!errors.password}
                      helperText={errors.password?.message}
                      placeholder="例: test"
                    />
                  )}
                />
              </Box>
            ) : (
              ""
            )}

            {/* age */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                年齢
              </InputLabel>
              <Controller
                name="age"
                control={control}
                render={({ field }) => (
                  <TextField
                    type="number"
                    size="small"
                    sx={{
                      flexGrow: 1,
                      "& .MuiFormHelperText-root": {
                        whiteSpace: "pre-line"
                      }
                    }}
                    {...field}
                    error={!!errors.age}
                    helperText={errors.age?.message}
                    placeholder="例: 20"
                  />
                )}
              />
            </Box>

            {/* hobby */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel id="hobby-label" size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                趣味
              </InputLabel>
              <Controller
                name="hobby"
                control={control}
                render={({ field }) => (
                  <FormControl size="small">
                    {/* renderInputはslotPropsに更新される可能性あり */}
                    <Autocomplete
                      {...field}
                      sx={{
                        width: 200,
                        flexGrow: 1,
                        "& .MuiFormHelperText-root": {
                          whiteSpace: "pre-line"
                        }
                      }}
                      options={hobbies}
                      getOptionLabel={(option) => option.label}
                      isOptionEqualToValue={(option, value) => option.code === value.code}
                      value={hobbies.find((element) => element.code === field.value) || null}
                      onChange={(event, newValue) => field.onChange(newValue?.code || "")}
                      renderInput={(params) => <TextField {...params} size="small" error={!!errors.hobby} helperText={errors.hobby?.message} placeholder="選択してください" />}
                      clearOnEscape
                    />
                  </FormControl>
                )}
              />
            </Box>

            {/* applyDate */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                適用日
              </InputLabel>
              <Controller
                name="applyDate"
                control={control}
                render={({ field }) => (
                  <DatePicker
                    value={field.value ? DateTime.fromISO(field.value) : null}
                    onChange={(newValue) => field.onChange(newValue ? newValue.toISO() : "")}
                    format="yyyy/MM/dd"
                    slotProps={{
                      textField: {
                        size: "small",
                        sx: {
                          flexGrow: 1,
                          "& .MuiFormHelperText-root": {
                            whiteSpace: "pre-line"
                          }
                        },
                        error: !!errors.applyDate,
                        helperText: errors.applyDate?.message,
                        placeholder: "日付を選択してください",
                        InputProps: {
                          /* TextFieldの右にクリアボタン埋込 */
                          endAdornment: field.value ? (
                            <ClearIcon
                              onClick={(e) => {
                                // フォーカス取らせない
                                e.stopPropagation();
                                field.onChange(null);
                              }}
                              fontSize="small"
                              sx={{ cursor: "pointer", color: "#888", ml: 1 }}
                            />
                          ) : null
                        }
                      }
                    }}
                  />
                )}
              />
            </Box>

            {/* isEnabled */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                有効
              </InputLabel>
              <Controller
                name="isEnabled"
                control={control}
                render={({ field }) => (
                  <FormControlLabel
                    label="有効にする"
                    sx={{
                      "& .MuiFormControlLabel-label": {
                        color: "rgba(0, 0, 0, 0.6)"
                      },
                      flexGrow: 1,
                      "& .MuiFormHelperText-root": {
                        whiteSpace: "pre-line"
                      }
                    }}
                    control={<Checkbox {...field} checked={field.value ? field.value : false} onChange={(e) => field.onChange(e.target.checked)} />}
                  />
                )}
              />
            </Box>

            {/* remarks */}
            <Box sx={{ mt: 2, display: "flex", alignItems: "flex-start" }}>
              <InputLabel size="small" sx={{ transform: "none", mt: 1, width: 200 }}>
                備考
              </InputLabel>
              <Controller
                name="remarks"
                control={control}
                render={({ field }) => (
                  <TextField
                    multiline
                    rows={3}
                    sx={{
                      flexGrow: 1,
                      "& .MuiFormHelperText-root": {
                        whiteSpace: "pre-line"
                      }
                    }}
                    size="small"
                    {...field}
                    error={!!errors.remarks}
                    helperText={errors.remarks?.message}
                  />
                )}
              />
            </Box>

            {/* hidden */}
            <input type="hidden" {...register("sortOrder")} />
            <input type="hidden" {...register("isDeleted")} />
            <input type="hidden" {...register("createdAt")} />
            <input type="hidden" {...register("updatedAt")} />
            <input type="hidden" {...register("createdBy")} />
            <input type="hidden" {...register("updatedBy")} />

            {/* Modal footer */}
            <Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
              <Button size="small" variant="contained" color="primary" onClick={handleSubmit(onSubmit)}>
                {!data ? "登録" : "更新"}
              </Button>
              {data && (
                <Button size="small" sx={{ ml: 1 }} variant="contained" color="error" onClick={() => onDelete(data)}>
                  削除
                </Button>
              )}
              <Button size="small" sx={{ ml: 1 }} variant="contained" color="inherit" onClick={onClose}>
                閉じる
              </Button>
            </Box>
          </Box>
        </LocalizationProvider>
      </ModalContainer>
    </>
  );
};

下記のコードは、Next.js の dev 実行で初回画面表示の描画速度を改善するために追加しています。
package のコンポーネントをプリロードして、通常は描画に約 15 秒かかるところを、約 5 秒で描画します。(useEffect でテーブルへのデータ反映を除く)

/common/AppInitializer.tsx
"use client";

/**************************************************
 * AppInitializer: 一部importのプリロードで高速化 + AG Grid ModuleRegistry
 *
 *
 **************************************************/

// Next.js、React
import { JSX } from "react";

// MUI
import { Autocomplete, Box, Button, Checkbox, FormControl, FormControlLabel, InputLabel, Modal, TextField, Typography, Snackbar, SnackbarContent, Backdrop, CircularProgress } from "@mui/material";

// MUI for DatePicker
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";

// MUI icon materials ※アイコンは重いため個別import
import ClearIcon from "@mui/icons-material/Clear";
import KeyboardDoubleArrowLeftIcon from "@mui/icons-material/KeyboardDoubleArrowLeft";
import KeyboardDoubleArrowRightIcon from "@mui/icons-material/KeyboardDoubleArrowRight";

// AG Grid
import { AgGridReact } from "ag-grid-react";
// Community版
import { ModuleRegistry, ClientSideRowModelModule, RowDragModule } from "ag-grid-community";
/* ValidationModule, MenuModule, ColumnsToolPanelModule, FiltersToolPanelModule, SetFilterModule, NumberFilterModule, TextFilterModule, StatusBarModule, SideBarModule, ClipboardModule, CsvExportModule */
// Enterprise版
// import { ExcelExportModule, MasterDetailModule, ServerSideRowModelModule, InfiniteRowModelModule, ViewportRowModelModule, RowGroupingModule, PivotModule, ChartsModule, SparklinesModule } from 'ag-grid-enterprise';

ModuleRegistry.registerModules([
  ClientSideRowModelModule,
  RowDragModule
  // Community版: ValidationModule, MenuModule, ColumnsToolPanelModule, FiltersToolPanelModule, SetFilterModule, NumberFilterModule, TextFilterModule, StatusBarModule, SideBarModule, ClipboardModule, CsvExportModule,
  // Enterprise版: ExcelExportModule, MasterDetailModule, ServerSideRowModelModule, InfiniteRowModelModule, ViewportRowModelModule, RowGroupingModule, PivotModule, ChartsModule, SparklinesModule
]);

// Chart.js
// ここにChart.jsのプリロードしたいものを記載

export const AppInitializer = (): JSX.Element => {
  /**************************************************
   * return JSX.Element: dummy 描画
   *
   **************************************************/
  return (
    <div style={{ display: "none" }}>
      <Autocomplete options={[]} renderInput={(params) => <TextField {...params} />} value={null} onChange={() => {}} />
      <Box></Box>
      <Button></Button>
      <Checkbox></Checkbox>
      <FormControl></FormControl>
      <FormControlLabel control={<Checkbox checked={false} onChange={() => {}} />} label="Dummy" />
      <InputLabel></InputLabel>

      <Modal open={false} onClose={() => {}}>
        <></>
      </Modal>

      <Typography></Typography>

      <Snackbar open={false} />
      <SnackbarContent />

      <Backdrop open={false}>
        <CircularProgress />
      </Backdrop>

      <LocalizationProvider dateAdapter={AdapterLuxon}>
        <DatePicker value={null} onChange={() => {}} />
      </LocalizationProvider>

      <ClearIcon />
      <KeyboardDoubleArrowLeftIcon />
      <KeyboardDoubleArrowRightIcon />

      <AgGridReact rowData={[]} columnDefs={[]} />
    </div>
  );
};
/app/layout.tsx
import { JSX } from "react";
import type { Metadata } from "next";
import { AppInitializer } from "@/common/AppInitializer";
import Header from "@/common/organisms/Header";
import Sidebar from "@/common/organisms/Sidebar";
import "./globals.css";

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app"
};

export default function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode;
}>): JSX.Element {
  return (
    <html lang="ja">
      <body>
        {/* Header */}
        <header>
          <Header />
        </header>

        {/* Main */}
        <main>
          {/* Sidebar */}
          <Sidebar />

          {/* Content */}
          <div className="content">
            {children}
            {/* 一部コンポーネントをプリロード + AG Grid Module登録 */}
            <AppInitializer />
          </div>
        </main>

        {/* Footer */}
        <footer></footer>
      </body>
    </html>
  );
}

(サンプルアプリの Web テーブル表示 実行結果)
Web テーブル (AG-Grid + Axios) 実行結果

(サンプルアプリのモーダル新規登録画面を表示 実行結果)
Web テーブル (AG-Grid + Axios) データ取得後 実行結果

(サンプルアプリの Web テーブルの行ダブルクリック後、モーダル編集画面を表示 実行結果)
Web テーブル (AG-Grid + Axios) データ取得後 実行結果


9-2. サンプルアプリの公開 URL (GitHub、StackBlitz で公開)

このページで最後に作成したサンプルアプリは GitHub 上でも公開し、ダウンロードできるようにしています。

(GitHub)
GitHub ではこのページで利用したアプリのソースコードを公開していて、ダウンロード可能です。
ダウンロード後は frontend、backend プロジェクトではそれぞれで npm install が必要です。

・next-root-app
https://github.com/mofuweb1/next-root-app

(StackBlitz)
StackBlitz では frontend の 1 プロジェクトのみで動くように修正して、デモとして公開しています。
TypeScript の基本や、React フックで紹介したコードは含まれていません。
全てのコードを確認したい場合は GitHub からダウンロードし、Visual Studio Code でご確認をお願いします。

StackBlitz では画面を表示した時に、自動で npm install が実行されます。npm install が完了するまで少し時間がかかります。

https://stackblitz.com/edit/stackblitz-starters-joyxqrm2


10. あとがき

Next.js での frontend の仕組みを一通り説明させていただきました。
初心者向けに作ったつもりが、後半は難しいところが多かったかもしれません。
文章が雑だったり、言葉足らずなところもありご容赦ください。

ケースによって作る画面、機能は異なってきますが、一覧画面、登録画面、モーダル画面の構築ができるようになれば、画面まわりの一通りのことはできて多少の応用もできていくていくのかなと思います。
ただし、実際はプログラムをしたら終わりでなく、ユニットテストをしたり、テストケースを作成してテストをしたり、ドキュメント作成なども一緒にします。

また、プログラミングを扱うときは技術に固執してしまったり、コードを書くだけみたいに思っている人とか、とりあえず動けばいいやとかって人もいるかもしれません。
新しい技術は取り入れていく必要はありますが、技術ばかりに固執せず、ユーザー側が求めている内容の理解、アプリのユーザービリティ、コード品質にも注意して、開発していくことが大切です。
今回の内容が、Next.js による TypeScript の学習とアプリ作成の第一歩としてお役に立てれば嬉しいです。

次回は backend 中心に API、データベース操作や、frontend + backend を合わせた認証関係などを扱いたいなと思います。

Discussion