【NextJS×MUI】Layoutにスナックバーを組み込む

# はじめに

[NextJS](https://nextjs.org/) × [MUI](https://mui.com/) でwebアプリを作成していて、ページ遷移後にスナックバーを表示したくなりました。

[MUIのsnackbar](https://mui.com/material-ui/react-snackbar/)はドキュメントを読む限りではDOM上に組み込んでおく必要があり、遷移後のページに組み込んでおいて遷移したことを検知して表示させるといったロジックが必要になってしまいます。
しかしこれでは遷移後のページが別ページのロジックを持つことになってしまい、嬉しくありません。
[Angular MaterialのMatSnackBar](https://material.angular.io/components/snack-bar/overview)や[Angular ionicのion-toast](https://ionicframework.com/docs/ja/api/toast)のように、関数を一つ叩くだけでページ関係なしに表示されて欲しくなります。

そこで、関数を叩けば下部にsnackbarを表示できるようにしてみました！

# 環境

- TypeScript
- NextJS
- [MUI](https://mui.com/material-ui/getting-started/installation/)
- [Redux Toolkit](https://www.npmjs.com/package/@reduxjs/toolkit)
- [React Redux](https://www.npmjs.com/package/react-redux)

# 実装方針

上記を達成するために以下の方針を立てました。

- NextJSの[Layout](https://nextjs.org/docs/basic-features/layouts)機能を用いて、表示したい部分に[Snackbar](https://mui.com/material-ui/react-snackbar/)を配置します
- 表示する・しないのステータスは[redux](https://redux.js.org/)で管理
- reduxのステータスを書き換えるグローバル関数を定義


# 実装

方針を元に実装していきます。

## _app.tsx

```tsx
import Layout from 'path/to/components/layout'
import { store } from 'path/to/store';

export default function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <Layout>
        <Component {...pageProps} />
      </Layout>
    </Provider>
  );
}
```

## layout.tsx

```tsx
import { ReactElement } from 'react'
import { useSelector } from 'react-redux';
import Container from '@mui/material/Container';

import { OpenSnackBar } from 'path/to/snackbar';
import { RootStateType } from 'path/to/store';

type LayoutProps = Required<{
    readonly children: ReactElement
}>;

const Layout = ({ children }: LayoutProps) => {
    const selector = useSelector((state: RootStateType) => state.snackbar);
    return (
        <>
            <Container maxWidth="lg" component="main">
                {children}
            </Container>
            <OpenSnackBar
                open={selector.isOpen}
                alertType={selector.alertType}
                message={selector.message}
            ></OpenSnackBar>
        </>
    );
};

export default Layout;
```

- メインコンテンツの入れ子として[Container](https://mui.com/material-ui/react-container/)を用いています。

## snackbar.tsx

```tsx
import Alert from '@mui/material/Alert';
import Snackbar from '@mui/material/Snackbar';

type snackbarType = 'error' | 'warning' | 'info' | 'success';

/** propsの型 */
interface PropType {
    /** スナックバーを開くかどうか */
    open: boolean;
    /** アラートの種類 */
    alertType: snackbarType;
    /** スナックバーに表示するメッセージ */
    message?: string;
}

export const OpenSnackBar = ({ open, alertType, message }: PropType) => {
    return (
        <Snackbar
            anchorOrigin={{
                vertical: 'bottom',
                horizontal: 'center',
            }}
            open={open}
            key={alertType}
        >
            <Alert severity={alertType}>{ message ?? alertType }</Alert>
        </Snackbar>
    );
};
```

- 下部真ん中に出るようにしてあります。
- [Alert](https://mui.com/material-ui/react-alert/)のタイプでスナックバーを出せるようにしています

## store.ts

```ts
import { configureStore, createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit'

type snackbarType = 'error' | 'warning' | 'info' | 'success';

type SnackbarStateType = {
    isOpen: boolean;
    alertType: snackbarType;
    message?: string;
};

export interface RootStateType {
    snackbar: SnackbarStateType;
}

const initialState: SnackbarStateType = {
    isOpen: false,
    alertType: 'success',
};

export const snackbarSlice = createSlice({
    name: "snackbar",
    initialState,
    reducers: {
        open(state, action: PayloadAction<{ alertType: snackbarType, message?: string }>) {
            state.isOpen = true;
            state.alertType = action.payload.alertType;
            state.message = action.payload.message;
            // 1.5秒後にスナックバーを閉じる
            setTimeout(() => {
                store.dispatch(snackbarSlice.actions.close());
            }, 1500);
        },
        close(state) {
            state.isOpen = false;
            state.message = undefined;
        }
    },
});

export const store = configureStore({
    reducer: {
        snackbar: snackbarSlice.reducer,
    },
});
```

## service.ts

```ts
import { store, snackbarSlice } from 'path/to/store';

type snackbarType = 'error' | 'warning' | 'info' | 'success';

/**
 * スナックバーを表示させる
 * @param alertType スナックバーの種類
 * @param message 表示したいメッセージ
 */
export function openSnackbar(alertType: snackbarType, message?: string) {
    store.dispatch(snackbarSlice.actions.open({ alertType, message }));
}
```

この `openSnackbar` 関数を叩くことでstore内のスナックバー状態が変更され、与えた引数 `alertType` に応じたスナックバーが下部真ん中に現れます。

# usage

```tsx
import type { NextPage } from 'next';
import Button from '@mui/material/Button';
import { openSnackbar } from 'path/to/service';

const TestPage: NextPage = () => {
  /** successスナックバーを表示 */
  const onOpenSnackBarButton = () => {
    openSnackbar('success');
  };
  return (
    <>
      <Button variant="contained" onClick={onOpenSnackBarButton}>スナックバーを表示</Button>
    </>
  );
};
```

## おわりに

Reduxが絡んでややこくなりますが、[Angular MaterialのMatSnackBar](https://material.angular.io/components/snack-bar/overview)や[Angular ionicのion-toast](https://ionicframework.com/docs/ja/api/toast)のように、関数を一つ叩くだけでページ関係なしにスナックバーを表示できるようになりました！
MUIの[Snackbar API](https://mui.com/material-ui/api/snackbar/)には `autoHideDuration ` という指定時間後に勝手に閉じてくれるプロパティがあるため、できることなら `setTimeout` は使いたくなかったですがstore内のsnackbarを表示するかの `isOpen` がtrueのままになってしまうためやむを得なくsetTimeoutしています。
もっといいやり方があれば教えてください。終わり！

# 参考

- [Next.jsにReduxを実装してみて](https://zenn.dev/nus3/articles/c2d86097029c12285680)