📤

共通化したコンポーネントライブラリでリンクを実装するのに一工夫必要だった話

2024/06/12に公開

こんにちは、steshimaです。
ソーシャルPLUS のフロントエンドには下記2つのアプリが存在し、それぞれ異なるルーティングライブラリを使用しています。

  • Social Login Manager(以下「SLM」)
    • Next Router
  • Message Manager(以下「MM」)
    • React Router

また、上記のアプリ内から呼び出す共通のコンポーネントライブラリ(@socialplus/components)が存在します。

今回、この @socialplus/components 内のコンポーネントの中でリンクを設定したい箇所がありましたが、一工夫必要だったのでそちらについて記事にしました。

リンクコンポーネントがアプリに依存している

元々、SLM と MM にはそれぞれリンクコンポーネントが実装されていました。
これらは MUILink コンポーネントと Next Router や React Router が提供する Link コンポーネントを組み合わせた自前のコンポーネントです。

それ自体はよくある設計だと思いますが、冒頭にも紹介した通り ソーシャルPLUS では歴史的経緯によりアプリ毎に採用されているルーティングライブラリが異なります。

そのため @socialplus/componentsに、アプリ側にあったリンクコンポーネントと同様のもので、且つどのアプリからも使える汎用的なリンクコンポーネントを実装するのが難しく、アプリ側で実装しているリンクコンポーネントを @socialplus/components へ渡し、共通コンポーネント内でそれを使う必要がありました[1]

React Context を使ってアプリからコンポーネントを渡す

コンポーネントを渡すといってもリンクを設定したい箇所が多いため、 Props で都度リンクコンポーネントを渡すという作りだと少し辛いです。

そのため React Context を使ってアプリからリンクコンポーネントを渡し、@socialplus/components内でそれを呼び出す設計にしています。
コードとしては下記のようなイメージです。

まず@socialplus/components側にコンテキストを生成し、それを使った共通コンポーネント内で使用するリンクコンポーネントを定義します。

socialplus-components/src/components/Link.tsx
export interface LinkProps {
  readonly href: string | UrlObject;
  ...
}

export const LinkComponentContext = createContext<React.FC<LinkProps> | null>(
  null,
);

export const Link: React.FC<LinkProps> = forwardRef((props, ref) => {
  const LinkComponent = useContext(LinkComponentContext);

  if (!LinkComponent) {
    throw new Error('LinkComponentContext Provider is not set');
  }

  return <LinkComponent {...props} ref={ref} />;
});

次に、コンテキストのプロバイダに渡すリンクコンポーネントを MM と SLM それぞれに定義します。

Props もアプリの実装に依存して型が微妙に違ったりなど差異があるので、ここで吸収して@socialplus/componentsのリンクコンポーネントの Props に合わせます。

まずは SLM。

social-login-manager/src/components/NextLink.tsx
import { MuiNextLink } from './MuiNextLink';

// 元々アプリ側に存在していた MUI と Next Router を組み合わせたリンクコンポーネント
const Link = React.forwardRef<HTMLAnchorElement, Props>(
  ({ href, noExternalIcon, children, ...rest }, ref) => {
    ...

    return (
      // MuiNextLink の中身は MUI の公式サンプルにある MUI と Next Router を組み合わせたコンポーネントをそのまま持ってきたものです
      // https://github.com/mui/material-ui/blob/f646bcc3c76dd3745cf8f5c7de8b29f33de7f7cc/examples/nextjs-with-typescript/src/Link.tsx
      <MuiNextLink ref={ref} href={href} {...rest}>
        {children}
        ...
      </MuiNextLink>
    );
  },
);

import { LinkProps } from '@socialplus/components';

export const NextLink: React.FC<LinkProps> = forwardRef(
  (
    { href, children, underline, ... },
    ref,
  ) => {
    // 型の絞り込みなど
    ...

    return (
      <Link
        ref={ref}
        href={href}
        underline={underline}
        ...
        >
        {children}
      </Link>
    );
  },
);

次に MM。

message-manager/src/components/ReactRouterLink.tsx
import { Link as MuiLink } from '@mui/material';
import {
    Link as RouterLink,
  } from 'react-router-dom';

// 元々アプリ側に存在していた MUI と React Router を組み合わせたリンクコンポーネント
const Link = React.forwardRef<HTMLAnchorElement, Props>(
    (
      { to, color, underline, children, noExternalIcon, target, ...rest },
      ref,
    ) => {
      ...
      
      const props = isExternal ? { href: to } : { component: RouterLink, to };
  
      return (
        <MuiLink
          ref={ref}
          {...props}
          target={target ?? (isExternal ? '_blank' : undefined)}
          ...
          >
          {children}
          ...
        </MuiLink>
      );
    },
  );
  
import { LinkProps } from '@socialplus/components';

export const ReactRouterLink: React.FC<LinkProps> = forwardRef(
  (
    { href, children, underline, ... },
    ref,
  ) => {

    // 型の絞り込みなど
    ...

    return (
      <Link
        ref={ref}
        href={href}
        underline={underline}
        ...
        >
        {children}
      </Link>
    );
  },
);

これでそれぞれのアプリにリンクコンポーネントを用意したので、アプリ側でプロバイダを使用して@socialplus/componentsにコンポーネントを受け渡すようにします。

例えば、SLM であれば下記の通り。

social-login-manager/src/pages/_app.page.tsx
const App: React.FC = () => (
  <LinkComponentContext.Provider value={NextLink}>
    ...
  </LinkComponentContext.Provider>
);

あとは共通コンポーネント内でリンクを設定したい箇所に、初めに定義した Link コンポーネントを import して使用するといった具合になります。

socialplus-components/src/components/CommonComponent.tsx
import { Link } from './Link';

export const CommonComponent: React.FC = (
  <div>
    ...
    <Link href="https://example.com/" />
  </div>
)

おわりに

今回はコンポーネントを自前のライブラリに渡したいというケースでしたが、他にもアプリに依存した情報を自前のライブラリに渡す必要があった場合、ライブラリ側にグローバルステート管理ライブラリなどを使ってアプリに依存するデータの置き場所を1つ作っておき、アプリの初期化処理などで必要な情報をまとめて設定するという方法もありそうだなとぼんやり考えています。

ただ、現状は依存するものが少なかったり、Props で受け渡すだけでも十分だったりと、今はまだそこまで凝った設計を考えなくても困っていないという状態です。

脚注
  1. ルーティングライブラリが提供している機能部分(Link コンポーネント)を抽象化すれば@socialplus/components内に汎用的なリンクコンポーネントを実装できますが、元々アプリ側にリンクコンポーネントを実装していたなどの理由で抽象化が難しそうなため、今はコンポーネント単位で分けています。 ↩︎

SocialPLUS Tech Blog

Discussion