Routing libraries

Routing libraries (라우팅 라이브러리)

Material UI 컴포넌트를 Next.js의 Link나 react-router 같은 라우팅 라이브러리와 통합하는 방법을 알려드릴게요. 사용자가 화면을 이동할 때 브라우저 표준 링크 대신 라우터의 기능을 활용하고 싶다면 잘 따라와 보세요.

출처: 문서

본문

기본적으로 내비게이션은 네이티브 <a> 요소로 수행돼요. 예를 들어 Next.js의 Link나 react-router를 사용하도록 커스터마이즈할 수 있어요.

내비게이션 컴포넌트 (Navigation components)

내비게이션을 수행하는 데 사용할 수 있는 두 가지 주요 컴포넌트가 있어요. 가장 일반적인 것은 이름에서 알 수 있듯이 Link예요. 네이티브 <a> 요소를 렌더링하고 href를 속성으로 적용해요.

import Link from '@mui/material/Link';
import Box from '@mui/material/Box';

export default function LinkDemo() {
  return (
    <Box sx={{ typography: 'body1' }}>
      <Link href="/">Link</Link>
    </Box>
  );
}

또한 버튼이 내비게이션 동작을 수행하게 만들 수도 있어요. 컴포넌트가 ButtonBase를 확장한다면, href prop을 제공하면 링크 모드가 활성화돼요. 예를 들어 Button 컴포넌트를 사용할 때:

import Button from '@mui/material/Button';

export default function ButtonDemo() {
  return (
    <Button href="/" variant="contained">
      Link
    </Button>
  );
}

실제 애플리케이션에서는 네이티브 <a> 요소만으로는 부족한 경우가 많아요. 향상된 Link 컴포넌트를 체계적으로 사용하면 사용자 경험을 개선할 수 있어요. Material UI 테마를 사용하면 이 컴포넌트를 한 번만 구성할 수 있어요. 예를 들어 react-router를 사용할 때:

import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router';
import { LinkProps } from '@mui/material/Link';

const LinkBehavior = React.forwardRef<
  HTMLAnchorElement,
  Omit<RouterLinkProps, 'to'> & { href: RouterLinkProps['to'] }
>((props, ref) => {
  const { href, ...other } = props;
  // Map href (Material UI) -> to (react-router)
  return <RouterLink ref={ref} to={href} {...other} />;
});

const theme = createTheme({
  components: {
    MuiLink: {
      defaultProps: {
        component: LinkBehavior,
      } as LinkProps,
    },
    MuiButtonBase: {
      defaultProps: {
        LinkComponent: LinkBehavior,
      },
    },
  },
});
import * as React from 'react';
import {
  Link as RouterLink,
  LinkProps as RouterLinkProps,
  MemoryRouter,
  StaticRouter,
} from 'react-router';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Link, { LinkProps } from '@mui/material/Link';

const LinkBehavior = React.forwardRef<
  HTMLAnchorElement,
  Omit<RouterLinkProps, 'to'> & { href: RouterLinkProps['to'] }
>((props, ref) => {
  const { href, ...other } = props;
  // Map href (MUI) -> to (react-router)
  return <RouterLink data-testid="custom-link" ref={ref} to={href} {...other} />;
});

function Router(props: { children?: React.ReactNode }) {
  const { children } = props;
  if (typeof window === 'undefined') {
    return <StaticRouter location="/">{children}</StaticRouter>;
  }

  return <MemoryRouter>{children}</MemoryRouter>;
}

const theme = createTheme({
  components: {
    MuiLink: {
      defaultProps: {
        component: LinkBehavior,
      } as LinkProps,
    },
    MuiButtonBase: {
      defaultProps: {
        LinkComponent: LinkBehavior,
      },
    },
  },
});

export default function LinkRouterWithTheme() {
  return (
    <Stack spacing={1} sx={{ alignItems: 'center', typography: 'body1' }}>
      <ThemeProvider theme={theme}>
        <Router>
          <Link href="/">Link</Link>
          <Button href="/" variant="contained">
            Link
          </Button>
        </Router>
      </ThemeProvider>
    </Stack>
  );
}

:::warning 이 접근 방식은 TypeScript에 제한이 있어요. href prop은 문자열만 허용해요. 더 풍부한 구조를 제공해야 한다면 다음 섹션을 참고하세요. :::

component prop

component prop으로 제3자 라우팅 라이브러리와 통합을 달성할 수 있어요. 이 prop에 대해 더 알아보려면 **composition guide**를 참고하세요.

React Router 예제 (React Router examples)

다음은 React Router의 Link 컴포넌트를 사용한 몇 가지 데모예요. BottomNavigation, Card 등 모든 컴포넌트에 같은 전략을 적용할 수 있어요.

import * as React from 'react';
import {
  Link as RouterLink,
  LinkProps as RouterLinkProps,
  MemoryRouter,
  StaticRouter,
} from 'react-router';
import Link from '@mui/material/Link';
import Box from '@mui/material/Box';

const LinkBehavior = React.forwardRef<any, Omit<RouterLinkProps, 'to'>>(
  (props, ref) => (
    <RouterLink
      ref={ref}
      to="/material-ui/getting-started/installation/"
      {...props}
    />
  ),
);

function Router(props: { children?: React.ReactNode }) {
  const { children } = props;
  if (typeof window === 'undefined') {
    return <StaticRouter location="/">{children}</StaticRouter>;
  }

  return <MemoryRouter>{children}</MemoryRouter>;
}

export default function LinkRouter() {
  return (
    <Box sx={{ typography: 'body1' }}>
      <Router>
        <Link component={RouterLink} to="/">
          With prop forwarding
        </Link>
        <br />
        <Link component={LinkBehavior}>Without prop forwarding</Link>
      </Router>
    </Box>
  );
}

Button

import * as React from 'react';
import {
  Link as RouterLink,
  LinkProps as RouterLinkProps,
  MemoryRouter,
  StaticRouter,
} from 'react-router';
import Button from '@mui/material/Button';

const LinkBehavior = React.forwardRef<any, Omit<RouterLinkProps, 'to'>>(
  (props, ref) => <RouterLink ref={ref} to="/" {...props} role={undefined} />,
);

function Router(props: { children?: React.ReactNode }) {
  const { children } = props;
  if (typeof window === 'undefined') {
    return <StaticRouter location="/">{children}</StaticRouter>;
  }

  return <MemoryRouter>{children}</MemoryRouter>;
}

export default function ButtonRouter() {
  return (
    <div>
      <Router>
        <Button component={RouterLink} to="/">
          With prop forwarding
        </Button>
        <br />
        <Button component={LinkBehavior}>With inlining</Button>
      </Router>
    </div>
  );
}

참고: ButtonBase 컴포넌트는 네이티브 <button> 요소 없이 버튼을 렌더링하려는 의도를 감지하면 role="button" 속성을 추가해요. 이로 인해 링크를 렌더링할 때 문제가 생길 수 있어요. href, to, component="a" prop 중 하나를 사용하지 않는다면 role 속성을 재정의해야 해요. 위 데모는 spread props 뒤에 role={undefined}를 설정해서 이를 달성해요.

const LinkBehavior = React.forwardRef((props, ref) => (
  <RouterLink ref={ref} to="/" {...props} role={undefined} />
));

Tabs

import * as React from 'react';
import Box from '@mui/material/Box';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import Typography from '@mui/material/Typography';
import {
  MemoryRouter,
  Route,
  Routes,
  Link,
  matchPath,
  useLocation,
  StaticRouter,
} from 'react-router';

function Router(props: { children?: React.ReactNode }) {
  const { children } = props;
  if (typeof window === 'undefined') {
    return <StaticRouter location="/drafts">{children}</StaticRouter>;
  }

  return (
    <MemoryRouter initialEntries={['/drafts']} initialIndex={0}>
      {children}
    </MemoryRouter>
  );
}

function useRouteMatch(patterns: readonly string[]) {
  const { pathname } = useLocation();

  for (let i = 0; i < patterns.length; i += 1) {
    const pattern = patterns[i];
    const possibleMatch = matchPath(pattern, pathname);
    if (possibleMatch !== null) {
      return possibleMatch;
    }
  }

  return null;
}

function MyTabs() {
  // You need to provide the routes in descendant order.
  // This means that if you have nested routes like:
  // users, users/new, users/edit.
  // Then the order should be ['users/add', 'users/edit', 'users'].
  const routeMatch = useRouteMatch(['/inbox/:id', '/drafts', '/trash']);
  const currentTab = routeMatch?.pattern?.path;

  return (
    <Tabs value={currentTab}>
      <Tab label="Inbox" value="/inbox/:id" to="/inbox/1" component={Link} />
      <Tab label="Drafts" value="/drafts" to="/drafts" component={Link} />
      <Tab label="Trash" value="/trash" to="/trash" component={Link} />
    </Tabs>
  );
}

function CurrentRoute() {
  const location = useLocation();

  return (
    <Typography variant="body2" sx={{ color: 'text.secondary', pb: 2 }}>
      Current route: {location.pathname}
    </Typography>
  );
}

export default function TabsRouter() {
  return (
    <Router>
      <Box sx={{ width: '100%' }}>
        <Routes>
          <Route path="*" element={<CurrentRoute />} />
        </Routes>
        <MyTabs />
      </Box>
    </Router>
  );
}

List

import * as React from 'react';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Box from '@mui/material/Box';
import ListItemButton from '@mui/material/ListItemButton';
import Paper from '@mui/material/Paper';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Divider from '@mui/material/Divider';
import InboxIcon from '@mui/icons-material/Inbox';
import DraftsIcon from '@mui/icons-material/Drafts';
import Typography from '@mui/material/Typography';
import {
  Link,
  Route,
  Routes,
  MemoryRouter,
  useLocation,
  StaticRouter,
} from 'react-router';

function Router(props: { children?: React.ReactNode }) {
  const { children } = props;
  if (typeof window === 'undefined') {
    return <StaticRouter location="/drafts">{children}</StaticRouter>;
  }

  return (
    <MemoryRouter initialEntries={['/drafts']} initialIndex={0}>
      {children}
    </MemoryRouter>
  );
}

interface ListItemLinkProps {
  icon?: React.ReactElement<unknown>;
  primary: string;
  to: string;
}

function ListItemLink(props: ListItemLinkProps) {
  const { icon, primary, to } = props;

  return (
    <ListItemButton component={Link} to={to}>
      {icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
      <ListItemText primary={primary} />
    </ListItemButton>
  );
}

function Content() {
  const location = useLocation();
  return (
    <Typography variant="body2" sx={{ color: 'text.secondary', pb: 2 }}>
      Current route: {location.pathname}
    </Typography>
  );
}

export default function ListRouter() {
  return (
    <Router>
      <Box sx={{ width: 360 }}>
        <Routes>
          <Route path="*" element={<Content />} />
        </Routes>

        <Paper elevation={0}>
          <List aria-label="main mailbox folders">
            <ListItem disablePadding>
              <ListItemLink to="/inbox" primary="Inbox" icon={<InboxIcon />} />
            </ListItem>
            <ListItem disablePadding>
              <ListItemLink to="/drafts" primary="Drafts" icon={<DraftsIcon />} />
            </ListItem>
          </List>
          <Divider />
          <List aria-label="secondary mailbox folders">
            <ListItem disablePadding>
              <ListItemLink to="/trash" primary="Trash" />
            </ListItem>
            <ListItem disablePadding>
              <ListItemLink to="/spam" primary="Spam" />
            </ListItem>
          </List>
        </Paper>
      </Box>
    </Router>
  );
}

더 많은 예제 (More examples)

Next.js Pages Router

예제 폴더는 Material UI와 함께 Next.js의 Link 컴포넌트를 사용하기 위한 어댑터를 제공해요.

  • 어댑터의 첫 번째 버전은 NextLinkComposed 컴포넌트예요. 이 컴포넌트는 스타일이 없으며 내비게이션 처리만 담당해요. href prop은 이름 충돌을 피하기 위해 to로 이름이 바뀌었어요. react-router의 Link 컴포넌트와 비슷해요.

    import Button from '@mui/material/Button';
    import { NextLinkComposed } from '../src/Link';
    
    export default function Index() {
      return (
        <Button
          component={NextLinkComposed}
          to={{
            pathname: '/about',
            query: { name: 'test' },
          }}
        >
          Button link
        </Button>
      );
    }
    
  • 어댑터의 두 번째 버전은 Link 컴포넌트예요. 이 컴포넌트는 스타일이 적용돼요. NextLinkComposed와 함께 Material UI Link 컴포넌트를 사용해요.

    import Link from '../src/Link';
    
    export default function Index() {
      return (
        <Link
          href={{
            pathname: '/about',
            query: { name: 'test' },
          }}
        >
          Link
        </Link>
      );
    }
    

TanStack Router

TanStack Router는 createLink 헬퍼 함수를 통해 커스텀 링크를 지원해요. 아래 스니펫은 Material UI Link 컴포넌트를 감싸는 가장 기본적인 구현을 보여줘요. 더 많은 컴포넌트 통합 예제는 TanStack Router—Custom Link를 참고하세요.

import { createLink } from '@tanstack/react-router';
import { Link as MUILink } from '@mui/material';

const CustomLink = createLink(MUILink);

function App() {
  return (
    <CustomLink underline="none" to="/about">
      Link to about page
    </CustomLink>
  );
}

더 알아보기 (Learn more)