:has()疑似クラスを使ったトランジション中のフィードバックを表示するアプローチ
はじめに 🚩
React で開発する際、あるコンポーネントで発生したトランジション(状態更新が保留)中のフィードバックを兄弟コンポーネントや親コンポーネントに伝えるのが難しい場合があります。これは、React の単一方向データフローの概念に起因しています。
特に Next.js の App Router 環境では、サーバーコンポーネント(親)側で context を使用して子コンポーネントの状態を共有したり、状態管理ライブラリ(Redux、Recoil など)の値を伝えることができません。また、親子関係がクライアントコンポーネント間の場合、context や状態管理ライブラリを使って値を共有することは可能であり、ケースによってはこれらの方法を使用する必要性があります。ただ、それらは多くのボイラープレートコードを必要とし、実装が複雑になることがあります。
CSS の:has()
疑似クラスを使用することで、この問題をシンプルに解決できます。:has()
は親要素の状態を子要素の状態に基づいてスタイリングできる CSS セレクターです。
この方法では、JavaScript を使用してトランジション状態を検出し、data 属性を設定する必要がありますが、その後のスタイリングは CSS で行うことができます。これにより、状態管理のロジックと UI の更新を分離し、よりシンプルな実装が可能になります。
実装例 📝
はじめに完成した状態のデモアプリを示します。
Gif 上だと伝わりづらいかもしれませんが、検索中やタグ選択中にカードのリストに対してpulse
アニメーションが発火しています。pulse アニメーションとは控えめながらも要素が周期的に拡大縮小する効果で、ユーザーの注意を引きつつ、処理中であることを視覚的に伝えます。
Tailwind CSS を使用している場合、animate-pulse
クラスを適用することで簡単にこのエフェクトを実現できます。
Page: サーバーコンポーネント
:has()
疑似クラスを使って、トランジション中のフィードバックを表示しています。data 属性(data-pending)を持つ要素の子要素に対してスタイルを適用します。data 属性の設定は MyTags, SearchInput コンポーネントで行っています。
export default async function Page({
searchParams,
}: {
searchParams: { search?: string; tagId?: string }
}) {
const searchQuery = searchParams.search
const tagId = searchParams.tagId
const [myTags, bookmarks] = await Promise.all([
getTags(),
getMyBookmarksByTag({
tagId: tagId ? Number(tagId) : undefined,
searchQuery,
})
]);
return (
<div className="container grid grid-cols-1 gap-4">
<SearchInput
className="mx-auto w-max max-w-full"
searchQuery={searchQuery || ""}
/>
<MyTags tags={myTags} tagId={tagId} />
+ <div className="grid grid-cols-1 gap-3 has-[[data-pending]]:animate-pulse md:grid-cols-2 lg:grid-cols-3">
{bookmarks.map((bookmark) => (
<BookmarkCard key={bookmark.id} bookmark={bookmark} />
))}
</div>
</div>
)
}
MyTags: クライアントコンポーネント
MyTags コンポーネントは以下の機能を持ちます:
- タグリストの表示: 与えられたタグのリストと「All」オプションを表示します。
- タグの選択機能: ユーザーがタグを選択でき、選択されたタグは視覚的に区別されます。
- URL 更新: タグ選択時に URL のクエリパラメータを更新し、「All」選択時にクリアします。
- 楽観的 UI 更新: useOptimistic を使用して、サーバーレスポンスを待たずに即座に UI 状態を更新します。
- トランジション状態の管理: useTransition を使用して状態更新中を示し、data-pending 属性で親コンポーネントに伝達します。
- パフォーマンス最適化: useCallback を使用して不要な再レンダリングを防ぎます。
type Props = {
tags: Tag[]
tagId?: string
}
export function MyTags({ tags, tagId }: Props) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [optimisticTagId, setOptimisticTagId] = useOptimistic(tagId)
const handleChange = useCallback(
(newTagId: string | null) => {
startTransition(() => {
setOptimisticTagId(newTagId ?? undefined)
router.push(newTagId ? `?tagId=${newTagId}` : "/")
})
},
[router, setOptimisticTagId]
)
return (
<div
className="flex flex-wrap gap-2"
data-pending={isPending ? "" : undefined}
>
<ToggleButton
variant="outline"
isSelected={!optimisticTagId}
onPress={() => handleChange(null)}
>
All
</ToggleButton>
{tags.map((tag) => (
<ToggleButton
variant="outline"
key={tag.id}
isSelected={tag.id === Number(optimisticTagId)}
onPress={() => handleChange(tag.id.toString())}
>
{tag.name}
</ToggleButton>
))}
</div>
)
}
data 属性を使って、トランジション中のフィードバックを表示します。トランジション中かどうかはuseTransition
のisPending
フラグを使って判断します。
data 属性は、HTML 要素にカスタムデータを格納するためのものです。data-*
の形式で定義され、JavaScript からアクセスしたり、CSS でスタイリングに利用したりできます。
今回は、以下のように data 属性を設定します:
data-pending={isPending ? "" : undefined}
SearchInput: クライアントコンポーネント
SearchInput コンポーネントは以下の機能を持ちます:
- 検索フィールド: ユーザーが検索キーワードを入力できます。
- リアルタイム検索: 入力値が変更されるたびに、URL のクエリパラメータを更新し、検索を実行します。
- デバウンス処理: 検索リクエストを 500 ミリ秒遅延させることで、連続した入力によるパフォーマンス低下を防ぎます。
- クリア機能: 検索フィールドをクリアし、URL パラメーターをリセットするボタンを提供します。
- ローディング表示: 検索中は、クリアボタンの代わりにローディングアイコンを表示します。
type Props = {
className?: string
searchQuery: string
}
export function SearchInput({ className, searchQuery }: Props) {
const [searchValue, setSearchValue] = useState(searchQuery)
const [isPending, startTransition] = useTransition()
const router = useRouter()
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
startTransition(async () => {
const value = e.target.value
setSearchValue(value)
const params = new URLSearchParams()
if (value) params.set("search", value)
await new Promise((resolve) => setTimeout(resolve, 500))
router.replace(`?${params.toString()}`)
})
}
const handleClearSearch = () => {
setSearchValue("")
router.replace("/")
}
return (
<SearchField className={cn("relative", className)}>
<SearchIcon className="absolute left-1" />
<SearchFieldInput
className="px-10"
placeholder="検索ワード"
value={searchValue}
onChange={handleSearch}
/>
<SearchActions
className="absolute right-1"
isSearching={isPending}
onClear={handleClearSearch}
/>
</SearchField>
)
}
function SearchActions({
className,
isSearching,
onClear,
}: {
className?: string
isSearching: boolean
onClear: () => void
}) {
return (
<div className={cn("flex items-center p-1", className)}>
{isSearching ? (
<Button
as="button"
data-pending={isSearching ? "" : undefined}
size="icon"
aria-label="Loading"
className="animate-spin"
variant="ghost"
>
<Icon name="Loader2" />
</Button>
) : (
<SearchFieldClearButton
aria-label="Clear search"
variant="ghost"
onPress={onClear}
/>
)}
</div>
)
}
my-tags.tsx と同様に、data 属性を使ってトランジション中のフィードバックを表示します。
<Button
data-pending={isSearching ? '' : undefined}
size='icon'
aria-label='Loading'
className='animate-spin'
variant='ghost'
>
<Icon name='Loader2' />
</Button>
group クラスを活用したエフェクトの最適化
現在の実装では、親コンポーネントであるpage.tsx
の最上位要素に pulse アニメーションを適用しているため、ページ全体にこのエフェクトが反映されてしまいます。しかし、UX の観点から、タグや検索フォームにはこのエフェクトを適用せず、カードのリスト部分にのみ適用することが理想的です。
この課題を解決するために、group
クラスを活用します。group
クラスは、親要素に適用することで、その子要素に特定のスタイルを適用できる機能です。
変更が必要な箇所はpage.tsx
のみです。
最上位の div に group クラスを追加することで、子要素に特定のスタイルを適用できるようになります。また、has-[[data-pending]]
を group-has-[[data-pending]]
に変更すると、子要素に data-pending 属性が存在する場合に、カードを含む div に pulse アニメーションが適用されます。
これにより、トランジション中のフィードバックをより細かく制御できるようになります。
// 省略
return (
- <div className='container grid grid-cols-1 gap-4'>
+ <div className='group container grid grid-cols-1 gap-4'>
<SearchInput
className='mx-auto w-max max-w-full'
searchQuery={searchQuery || ''}
/>
<MyTags tags={myTags} tagId={tagId} />
- <div className='grid grid-cols-1 gap-3 has-[[data-pending]]:animate-pulse md:grid-cols-2 lg:grid-cols-3'>
+ <div className='grid grid-cols-1 gap-3 group-has-[[data-pending]]:animate-pulse md:grid-cols-2 lg:grid-cols-3'>
{bookmarks.map((bookmark) => (
<BookmarkCard key={bookmark.id} bookmark={bookmark} />
))}
</div>
</div>
);
おわりに 🏁
この記事では、:has()
疑似クラスと data 属性を組み合わせたトランジションフィードバックの実装方法について紹介しました。
この手法を活用することで、パフォーマンスを維持しながらユーザー体験を向上させることが可能です。特に、Next.js の App Router 環境下でのサーバーコンポーネントとクライアントコンポーネント間の状態共有の課題に対して、シンプルな解決策を提供します。
また、今回は pulse アニメーションを例として使用しましたが、この手法は他の CSS アニメーションやフィードバック効果にも応用できます。さらに、group
クラスを活用することで、より細かな制御が可能になり、UX の向上につながります。
以上です!
ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion
今回のサンプルコードの場合、
data-pending
の代わりにaria-busy
を使う手があるかもしれないと思いました👍コメントありがとうございますー!
アクセシビリティの観点から data 属性よりも推奨される感じですかね。
また手元で試してみようとおもいます!