Reach UIからRadix UIに移行しました

  • URLをコピーしました!

こんにちは。開発部フロントエンドエンジニアの古園(@miyabin4113)です。

先日PR TIMESの全コンポーネントからReach UIを剥がし、Radix UIに移行することが完了しました。

PR TIMESでは当初「アクセシビリティを最優先に考えて設計されている」「スタイル無しでカスタマイズが柔軟」などの理由からReach UIを使用していました。

あわせて読みたい
Reach UIを利用してPR TIMESのフロントエンドを実装した話 こんにちは、21新卒のフロントエンドのTepy(テッピー)です。 お読み頂いた方がいるかもしれませんが、先日こちらのReactの便利なライブラリーを紹介する記事を書きま...

ですが、近年Reach UIはあまりメンテナンスされておらず2024年12月に安定版がリリースされたReactのバージョン19だけでなく18から未対応という問題がありました。

そこで新しく作成するコンポーネントはRadix UIで作成していたのですが、Reach UIで作成したコンポーネントがまだ残っており、React19へのアップデートに向けた障壁の1つになっていました。

あわせて読みたい
Radix UIを利用してエディターのコンポーネントを実装した話 こんにちは。2023年の7月に中途で開発本部に入社しました。フロントエンドエンジニアの夛田(@unachang113)です 昨年12月にリリースされたPR TIMESの新エディターの開発...
目次

何を行ったのか

PR TIMES環境内に残っていたReach UIのモジュールは以下の通りです。

  • @reach/portal
  • @reach/tabs
  • @reach/menu-button
  • @reach/listbox

それぞれ順に解説していきます。

@reach/portal

こちらは@radix-ui/react-portalに移行しました。どちらも似た構成をしていたのでコンポーネントの差し替えのみで簡単に移行することができました。

@reach/portal使用時のコード

import {Portal} from '@reach/portal';

export function Portal() {
  return (
    <Portal>
      <div>ポータルの中身</div>
    </Portal>
  );
}

@radix-ui/react-portalへ変更後のコード

import {Root} from '@radix-ui/react-portal';

export function Portal() {
  return (
    <Root>
      <div>ポータルの中身</div>
    </Root>
  );
}

@reach/tabs

こちらは@radix-ui/react-tabsに移行しています。コンポーネントは以下の通りで

  • reach/tabsのTabPanelsの部分はradix-ui/react-tabsでは不要
  • valueに渡せる値がstringのみに(Radix UIのvalueはこれ以外もstringしか渡せません)
  • 選択されているタブを示すデータ属性がdata-selectedからdata-state=’active’に

といった変更点がありました。

@reach/tabs使用時のコード

import {Tabs, TabList, Tab, TabPanels, TabPanel} from '@reach/tabs';
import {useState} from 'react';

export function Tabs() {
	const [tabIndex, setTabIndex] = useState<number>(1);
	
  return (
    <Tabs value={tabIndex} onValueChange={(value) => setTabIndex(value)}>
      <TabList>
        <Tab value={1}>プレスリリース</Tab>
        <Tab value={2}>ストーリー</Tab>
        <Tab value={3}>プレスキット</Tab>
      </TabList>

      <TabPanels>
        <TabPanel value={1}>
          <div>プレスリリース記事の中身<div>
        </TabPanel>
        <TabPanel value={2}>
          <div>ストーリー記事の中身</div>
        </TabPanel>
        <TabPanel value={3}>
          <div>プレスキットの中身</div>
        </TabPanel>
      </TabPanels>
    </Tabs>
  );
}

@radix-ui/react-tabsへ変更後のコード

import {Root, List, Trigger, Content} from '@radix-ui/react-tabs';
import {useState} from 'react';

export function Tabs() {
	const [tabIndex, setTabIndex] = useState<string>('1');
	
  return (
    <Root value={tabIndex} onValueChange={(value) => setTabIndex(value)}>
      <List>
        <Trigger value={1.toString()}>プレスリリース</Trigger>
        <Trigger value={2.toString()}>ストーリー</Trigger>
        <Trigger value={3.toString()}>プレスキット</Trigger>
      </List>

      <Content value={1.toString()}>
        <div>プレスリリース記事<div>
      </Content>
      <Content value={2.toString()}>
        <div>ストーリー記事</div>
      </Content>
      <Content value={3.toString()}>
        <div>プレスキット</div>
      </Content>
    </Root>
  );
}

@reach/menu-button

こちらは@radix-ui/react-dropdown-menuに移行しました。

@reach/menu-button使用時のコード

import {Menu, MenuButton, MenuPopover, MenuList, MenuLink} from '@reach/menu-button';

export function Menu() {
	return (
		<Menu>
      <MenuButton>
        管理画面
      </MenuButton>
      
      <MenuPopover>
	      <MenuList>
		      <MenuLink as="a" href='/'>
	          <img src="./logo.png" alt="prtimes" />
	        </MenuLink>
	        <MenuLink as="a" href='/'>
	          PR TIMES サイトへ戻る
	        </MenuLink>
	      </MenuList>
	    </MenuPopover>
    </Menu>
	);
}

@radix-ui/react-dropdown-menuへ変更後のコード

import {Root, Trigger, Portal, Content, Item} from '@radix-ui/react-dropdown-menu';

export function Menu() {
	return (
		<Root>
      <Trigger>
        管理画面
      </Trigger>
      
      <Portal>
	      <Content>
		      <Item>
			      <a href='/'>
		          <img src="./logo.png" alt="prtimes" />
		        </a>
		      </Item>
	        <Item>
		        <a href='/'>
		          PR TIMES サイトへ戻る
		        </a>
	        </Item>
	      </Content>
	    </Portal>
    </Root>
	);
}

上記のようにコンポーネントの差し替えは特に問題なかったのですが、ポップアップが表示される挙動が変化していました。reach/menu-buttonは非表示から表示に変わる挙動でDOMには予めポップアップの部分が存在しています。

メニューを閉じた状態
メニューを開いた状態

反対にradix-ui/react-dropdown-menuはDOMが存在せず、ボタンを押してから初めてBodyタグ直下にDOMが生成されます。

メニューを閉じた状態
メニューを開いた状態

この挙動に変わったことで「メニューを開いていない時に『PR TIMES サイトへ戻る』という文字が存在していること」といったポップアップ内の要素を取得するテストの変更が必要になりました。

PR TIMESのフロントエンドはvitestで作成されているので、reach/menu-buttonの場合は存在している文字を取得できる getByText を使用していました。ですが、このままだと移行後は文字を取得することができずにテストが落ちてしまいます。

// before
it('メニューを閉じている時、「管理画面」は表示され「PR TIMES サイトへ戻る」は存在しているが非表示になっていること', () => {
  const {getByText, asFragment} = render(<Menu />);

  expect(getByText('管理画面')).toBeVisible();
  // ここで「PR TIMES サイトへ戻る」が存在しないというエラーが出る
  expect(getByText('PR TIMES サイトへ戻る')).not.toBeVisible();
  expect(getByText('PR TIMES サイトへ戻る')).toBeInTheDocument();
  expect(asFragment()).toMatchSnapshot();
});

そういった箇所は全て文字が存在しない場合はNullを返してくれる queryBeText に置き換えてテストが通るように修正しました。また、それに合わせて確認する内容も変更しています。

// after
it('メニューを閉じている時、「管理画面」は表示され「PR TIMES サイトへ戻る」はDOMに存在していない状態になっていること', () => {
  const {getByText, queryByText, asFragment} = render(<Menu />);

  expect(getByText('管理画面')).toBeVisible();
  // 「PR TIMES サイトへ戻る」はDOMに存在していないため
  // getByTextからqueryByTextに変更し、.not.toBeInTheDocument()のみ確認するよう変更
  expect(queryByText('PR TIMES サイトへ戻る')).not.toBeInTheDocument();
  expect(asFragment()).toMatchSnapshot();
});

@reach/listbox

Radix UIにはlistboxモジュールが存在しなかったので似た挙動が行える@radix-ui/react-selectで作り直しました。

@reach/listbox使用時のコード

import {ListboxInput, ListboxButton, ListboxPopover, ListboxList, ListboxOption} from '@reach/listbox';

type Selector = '最終記事取得日 新しい順' | '最終記事取得日 古い順' | 'クリップ名 昇順' | 'クリップ名 降順';

export function Selector() {
	const [value, setValue] = useState<Selector>('updatedAtDesc');
	
	return (
		<ListboxInput required value={value} onChange={(value) => setValue(value)}>
	    <ListboxButton arrow={<ArrowIcon variant='gray' direction='down' />} />
	    
	    <ListboxPopover>
	      <ListboxList>
	        <ListboxOption value='最終記事取得日 新しい順'>
	          最終記事取得日 新しい順
	        </ListboxOption>
	        <ListboxOption value='最終記事取得日 古い順'>
	          最終記事取得日 古い順
	        </ListboxOption>
	        <ListboxOption value='クリップ名 昇順'>
	          クリップ名 昇順
	        </ListboxOption>
	        <ListboxOption value='クリップ名 降順'>
	          クリップ名 降順
	        </ListboxOption>
	      </ListboxList>
	    </ListboxPopover>
	  </ListboxInput>
	);
}

@radix-ui/react-selectへ変更後のコード

import {Root, Trigger, Value, Icon, Portal, Content, Viewport, Item} from '@radix-ui/react-select';

type Selector = '最終記事取得日 新しい順' | '最終記事取得日 古い順' | 'クリップ名 昇順' | 'クリップ名 降順';

export function Selector() {
	const [value, setValue] = useState<Selector>('最終記事取得日 新しい順');
	
	return (
    <Root required value={value} onValueChange={(value) => setValue(value)}>
      <Trigger>
        <Value>
          {value}
        </Value>
        <Icon asChild>
          <ArrowIcon variant='gray' direction='down' />
        </Icon>
      </Trigger>
      
      <Portal>
        <Content position='popper' align='start'>
          <Viewport>
            <Item value='最終記事取得日 新しい順'>
              最終記事取得日 新しい順
            </Item>
            <Item value='最終記事取得日 古い順'>
              最終記事取得日 古い順
            </Item>
            <Item value='クリップ名 昇順'>
              クリップ名 昇順
            </Item>
            <Item value='クリップ名 降順'>
              クリップ名 降順
            </Item>
          </Viewport>
        </Content>
      </Portal>
    </Root>
  );
}

まとめ

今回の対応でReact19へのアップデートが少し前進しましたが、まだRecoilを剥がすという大きな障壁が残っています。そちらについては先日の「株式会社ユーザベース×株式会社ZOZO×株式会社PR TIMES 3社合同フロントエンド勉強会」にて発表がありましたので詳しくはこちらの記事をご覧ください。

あわせて読みたい
「株式会社ユーザベース×株式会社ZOZO×株式会社PR TIMES 3社合同フロントエンド勉強会」 を開催しました こんにちは、フロントエンドエンジニアの桐澤(@kiririLee)です。2024年12月10日に株式会社ユーザベースさんと株式会社ZOZOさんと合同でフロントエンド勉強会を開催し...

We are hiring!

PR TIMESではフロントエンドエンジニアを含む各種ポジションでの採用を進めています!興味がありましたらぜひご応募ください!

あわせて読みたい
株式会社PR TIMES
02.開発部 の求人一覧 - 株式会社PR TIMES 株式会社PR TIMESが公開している、02.開発部 の求人一覧です
  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発部 フロントエンドエンジニア

目次