こんにちは。開発部フロントエンドエンジニアの古園(@miyabin4113)です。
先日PR TIMESの全コンポーネントからReach UIを剥がし、Radix UIに移行することが完了しました。
PR TIMESでは当初「アクセシビリティを最優先に考えて設計されている」「スタイル無しでカスタマイズが柔軟」などの理由からReach UIを使用していました。

ですが、近年Reach UIはあまりメンテナンスされておらず2024年12月に安定版がリリースされたReactのバージョン19だけでなく18から未対応という問題がありました。
そこで新しく作成するコンポーネントはRadix UIで作成していたのですが、Reach UIで作成したコンポーネントがまだ残っており、React19へのアップデートに向けた障壁の1つになっていました。

何を行ったのか
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社合同フロントエンド勉強会」にて発表がありましたので詳しくはこちらの記事をご覧ください。

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

