💡

:has()疑似クラスを使ったトランジション中のフィードバックを表示するアプローチ

2024/10/09に公開2

はじめに 🚩

React で開発する際、あるコンポーネントで発生したトランジション(状態更新が保留)中のフィードバックを兄弟コンポーネントや親コンポーネントに伝えるのが難しい場合があります。これは、React の単一方向データフローの概念に起因しています。

特に Next.js の App Router 環境では、サーバーコンポーネント(親)側で context を使用して子コンポーネントの状態を共有したり、状態管理ライブラリ(Redux、Recoil など)の値を伝えることができません。また、親子関係がクライアントコンポーネント間の場合、context や状態管理ライブラリを使って値を共有することは可能であり、ケースによってはこれらの方法を使用する必要性があります。ただ、それらは多くのボイラープレートコードを必要とし、実装が複雑になることがあります。

CSS の:has()疑似クラスを使用することで、この問題をシンプルに解決できます。:has()は親要素の状態を子要素の状態に基づいてスタイリングできる CSS セレクターです。

https://developer.mozilla.org/ja/docs/Web/CSS/:has

この方法では、JavaScript を使用してトランジション状態を検出し、data 属性を設定する必要がありますが、その後のスタイリングは CSS で行うことができます。これにより、状態管理のロジックと UI の更新を分離し、よりシンプルな実装が可能になります。

実装例 📝

はじめに完成した状態のデモアプリを示します。

alt text

Gif 上だと伝わりづらいかもしれませんが、検索中やタグ選択中にカードのリストに対してpulseアニメーションが発火しています。pulse アニメーションとは控えめながらも要素が周期的に拡大縮小する効果で、ユーザーの注意を引きつつ、処理中であることを視覚的に伝えます。

Tailwind CSS を使用している場合、animate-pulseクラスを適用することで簡単にこのエフェクトを実現できます。

Page: サーバーコンポーネント

:has()疑似クラスを使って、トランジション中のフィードバックを表示しています。data 属性(data-pending)を持つ要素の子要素に対してスタイルを適用します。data 属性の設定は MyTags, SearchInput コンポーネントで行っています。

page.tsx
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 コンポーネントは以下の機能を持ちます:

  1. タグリストの表示: 与えられたタグのリストと「All」オプションを表示します。
  2. タグの選択機能: ユーザーがタグを選択でき、選択されたタグは視覚的に区別されます。
  3. URL 更新: タグ選択時に URL のクエリパラメータを更新し、「All」選択時にクリアします。
  4. 楽観的 UI 更新: useOptimistic を使用して、サーバーレスポンスを待たずに即座に UI 状態を更新します。
  5. トランジション状態の管理: useTransition を使用して状態更新中を示し、data-pending 属性で親コンポーネントに伝達します。
  6. パフォーマンス最適化: useCallback を使用して不要な再レンダリングを防ぎます。
my-tags.tsx
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 属性を使って、トランジション中のフィードバックを表示します。トランジション中かどうかはuseTransitionisPendingフラグを使って判断します。

data 属性は、HTML 要素にカスタムデータを格納するためのものです。data-*の形式で定義され、JavaScript からアクセスしたり、CSS でスタイリングに利用したりできます。

https://developer.mozilla.org/ja/docs/Learn/HTML/Howto/Use_data_attributes

今回は、以下のように data 属性を設定します:

data-pending={isPending ? "" : undefined}

SearchInput: クライアントコンポーネント

SearchInput コンポーネントは以下の機能を持ちます:

  1. 検索フィールド: ユーザーが検索キーワードを入力できます。
  2. リアルタイム検索: 入力値が変更されるたびに、URL のクエリパラメータを更新し、検索を実行します。
  3. デバウンス処理: 検索リクエストを 500 ミリ秒遅延させることで、連続した入力によるパフォーマンス低下を防ぎます。
  4. クリア機能: 検索フィールドをクリアし、URL パラメーターをリセットするボタンを提供します。
  5. ローディング表示: 検索中は、クリアボタンの代わりにローディングアイコンを表示します。
search-input.tsx
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クラスは、親要素に適用することで、その子要素に特定のスタイルを適用できる機能です。

https://tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-parent-state

変更が必要な箇所はpage.tsxのみです。

最上位の div に group クラスを追加することで、子要素に特定のスタイルを適用できるようになります。また、has-[[data-pending]]group-has-[[data-pending]]に変更すると、子要素に data-pending 属性が存在する場合に、カードを含む div に pulse アニメーションが適用されます。

これにより、トランジション中のフィードバックをより細かく制御できるようになります。

page.tsx
// 省略

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. tech blog

Discussion

ootideaootidea

今回のサンプルコードの場合、data-pendingの代わりにaria-busyを使う手があるかもしれないと思いました👍

TsuboiTsuboi

コメントありがとうございますー!
アクセシビリティの観点から data 属性よりも推奨される感じですかね。

また手元で試してみようとおもいます!