📑

MUIのuseMediaQueryをNext.js(v14 App Router)で動作確認

2024/05/27に公開

Next.js integration対応を行なった上でuseMediaQueryを試していく。

ゴール

準備

Next.js & MUIの基本的なセットアップ

src/app/layout.tsx
import { CssBaseline, ThemeProvider } from '@mui/material';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import { theme } from './styles/theme';

export default function RootLayout({
    children,
}: Readonly<{
    children: React.ReactNode;
}>) {
    return (
        <html lang="ja">
            <head>
                <meta name="viewport" content="initial-scale=1, width=device-width" />
            </head>
            <body>
                <CssBaseline />
                <ThemeProvider theme={theme}>
                    <AppRouterCacheProvider>{children}</AppRouterCacheProvider>
                </ThemeProvider>
            </body>
        </html>
    );
}
src/styles/theme.ts
'use client';

import { createTheme } from '@mui/material';

export const theme = createTheme();
src/app/page.tsx
import { Box } from '@mui/material';
import { SampleComponent } from './SampleComponent';

export default function Home() {
    return (
        <Box component="main">
            <SampleComponent /> {/* このコンポーネント内でuseMediaQueryを試す */}
        </Box>
    );
}

基本的な動作を確認

stc/app/SampleComponent.tsx
'use client'; // 必須

import { Box, Theme, Typography, useMediaQuery, useTheme } from '@mui/material';
import { grey } from '@mui/material/colors';

/**
 * 画面幅が600px以上か否かの状態(matches)を取得する
 */
export const SampleComponent = () => {
    // const matches = useMediaQuery('(min-width:600px)'); // これでもOK

    // const theme = useTheme();
    // const matches = useMediaQuery(theme.breakpoints.up('sm')); // これでもOK

    const matches = useMediaQuery((theme: Theme) => theme.breakpoints.up('sm')); // ThemeProvider配下であればこれでもOK
    // matchesはtrueかfalse

    return (
        <Box width={400} sx={{ backgroundColor: grey[200] }}>
            <Typography variant="h5">{`(min-width:600px) matches: ${matches}`}</Typography>
        </Box>
    );
};

  • React Developer Tools で確認するとウィンドウサイズが600pxをクロスする際にのみコンポーネントが再レンダリング(ハイライト)されていることが分かる。
  • 上記gifには映っていないがSSRの結果としてはmatchの値はデフォルトのfalseである(つまり600px以上の画面でリロードすると一瞬falseが表示されたのちにtrue表示となる)。このデフォルト値は以下のようにcreateTheme内で上書きできる。
export const theme = createTheme({
    components: {
        MuiUseMediaQuery: {
            defaultProps: {
                defaultMatches: true, // サーバーサイドレンダリング時にtrueを返す
            },
        },
    },
});

応用(カスタムフック、プロバイダー)

Next.js(React)&MUIのレスポンシブデザインの仕組み作りの1例として、画面幅が3種類のデバイス幅のうちのどれであるかを監視、提供するカスタムフック、プロバイダーをuseMediaQueryを使用して作ってみる。

src/components/providers/ResponsiveProvider.tsx
'use client'; // 必須

import { Theme, useMediaQuery } from '@mui/material';
import { ReactNode, createContext, useContext } from 'react';

// 3種それぞれの画面幅であるか否かを管理するReact Contextを作成
const ResponsiveContext = createContext<{ isMobile: boolean; isTablet: boolean; isDesktop: boolean } | undefined>(
    undefined
);

// React Contextを使ってプロバイダーコンポーネントを作成
export const ResponsiveProvider = ({ children }: { children: ReactNode }) => {
    // 常に以下のうちの1つだけがtrueになるようにブレークポイントを設定
    const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'));
    const isTablet = useMediaQuery((theme: Theme) => theme.breakpoints.between('sm', 'md'));
    const isDesktop = useMediaQuery((theme: Theme) => theme.breakpoints.up('md'));

    return (
        <ResponsiveContext.Provider value={{ isMobile, isTablet, isDesktop }}>{children}</ResponsiveContext.Provider>
    );
};

// React Contextの値をデバイスタイプに変換して返すフックを作成
export const useResponsive = () => {
    const context = useContext(ResponsiveContext);
    if (context === undefined) {
        // ResponsiveProviderコンポーネント配下からの呼び出しでない場合とみなす
        throw new Error('useResponsive must be used within a ResponsiveProvider');
    }

    const { isMobile, isTablet, isDesktop } = context;
    let deviceType: DeviceType = 'unknown';
    if (isMobile) {
        deviceType = 'mobile';
    } else if (isTablet) {
        deviceType = 'tablet';
    } else if (isDesktop) {
        deviceType = 'desktop';
    }

    return deviceType;
};

export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
src/app/layout.tsx(修正)
import { ResponsiveProvider } from '@/components/providers/ResponsiveProvider';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import { theme } from './styles/theme';

export default function RootLayout({
    children,
}: Readonly<{
    children: React.ReactNode;
}>) {
    return (
        <html lang="ja">
            <head>
                <meta name="viewport" content="initial-scale=1, width=device-width" />
            </head>
            <body>
                <CssBaseline />
                <ThemeProvider theme={theme}>
                    <ResponsiveProvider> {/* ←追加 */}
                        <AppRouterCacheProvider>{children}</AppRouterCacheProvider>
                    </ResponsiveProvider>
                </ThemeProvider>
            </body>
        </html>
    );
}
src/app/SampleComponent.tsx(修正)
'use client'; // 必須

import { useResponsive } from '@/components/providers/ResponsiveProvider';
import { Box, Typography } from '@mui/material';
import { grey } from '@mui/material/colors';

/**
 * 画面幅がモバイル幅、タブレット幅、デスクトップ幅のいずれであるかをを取得する
 */
export const SampleComponent = () => {
    const deviceType = useResponsive();

    return (
        <Box width={600} sx={{ backgroundColor: grey[200] }}>
            <Typography variant="h6">{`(max-width:599px) isMobile: ${deviceType === 'mobile'}`}</Typography>
            <Typography variant="h6">{`(min-width:600px) and (max-width:899px) isTablet: ${
                deviceType === 'tablet'
            }`}</Typography>
            <Typography variant="h6">{`(min-width:900px) isTablet: ${deviceType === 'desktop'}`}</Typography>

            {deviceType === 'mobile' && <Typography variant="h6">モバイルサイズ</Typography>}
            {deviceType === 'tablet' && <Typography variant="h6">タブレットサイズ</Typography>}
            {deviceType === 'desktop' && <Typography variant="h6">デスクトップサイズ</Typography>}
        </Box>
    );
};

  • この例ではSSR時点では3種のデバイス全てがfalseである(deviceTypeが'unkown')。そのためSSR時点では「モバイルサイズ」「タブレットサイズ」「デスクトップサイズ」のいずれのDOM要素も生成されていない。SEOが重要な場合はdeviceType === 'unkown'の際にもいずれかのデバイス幅向けのコンテンツがSSR時点で生成されるようにしておいた方が良いだろう。

Discussion