🌀

処理時間が長いAPIをリクエストしたときのローディングUIについて

2024/03/31に公開

こんにちは!@Ryo54388667です!☺️

普段は都内でフロントエンドエンジニアとして業務をしてます!
主にTypeScriptやNext.jsといった技術を触っています。

今回はレスポンスの遅いAPIをリクエストしたときのローディングUIを紹介したいと思います。

📌 作ろうと思った背景

きっかけは生成AIのエージェントAPIを利用するとき、レスポンスを待つ時間が非常に長かったからです。。😇
生成AIのプロダクトではプロンプトを解析したり、最適な問い合わせ先を判断したり、オリジンにリクエストするなど、さまざまな処理を行う(オーケストレーションレイヤー)関係でレスポンス時間が遅くなることがあります。これをなんとかしたいと思ったのがきっかけです。

昨今の開発では、UXをいかに上げるかが求められているように感じます。できることが格段に上がったとしても、UXが悪くて利用されなくなっては本末転倒です。「レスポンスが遅いのは後からやろうか。。」このような先送りが許容されなくなりつつあるのかなと思っています。

この状況を解決するために、ローディングUIを工夫することを考えました!
ローディング状況がわかるなどの工夫があると、体験が全然違うなーと感じます。

ローディングUIの工夫を体感しやすい例は、ChatGPTのDALL.Eかなと思います。
現状、画像生成APIを利用したとき、画像の生成時間が非常に長い。通常のテキスト生成APIと比べても体感できるほど生成時間の違いがあります。

確かに、待たされる時間は長いのですが、ローディングUIが工夫されていることで、処理が確実に進んでいることがわかるので安心して待てます。このUIがなかったら「ほんとに動いてんのかなぁ。。」と不安でしかたありません。体感的な話なので自分だけかもですが。。

もちろん、「元のAPIの速度を改善しないと根本的な解決ではなくね!?」というのもわかります😅あくまで回避としての解決策で恐縮ですが、ひと工夫したローディングUIを作成してみました。

📌 コードについて

準備

今回は自分がよく利用しているのでTailwindCSSを採用しました。プレーンなCSSでも全く問題ありません。あと、レスポンスの遅いAPIを再現するためにNext.jsを利用しました。

Package

name version
Next 14.0.0
React 18.2.0

サンプルのAPIを作成しました。
sleepで意図的に遅くしています。いくつかのAPIリクエストを行うたびにフロントにyieldでレスポンスする想定です。

// api/sample/route.ts
async function* makeIterator() {
  await sleep(1000)
  yield encoder.encode('{"text":"called"}')
  await sleep(3000)
  yield encoder.encode('{"text":"running"}')
  await sleep(3000)
  yield encoder.encode('{"text":"finished"}')
}
/api/sample/route.ts 全文
function iteratorToStream(iterator: any) {
  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await iterator.next()
      if (done) {
        controller.close()
      } else {
        controller.enqueue(value)
      }
    },
  })
}

function sleep(time: number) {
  return new Promise((resolve) => {
    setTimeout(resolve, time)
  })
}

const encoder = new TextEncoder()

async function* makeIterator() {
  await sleep(1000)
  yield encoder.encode('{"text":"called"}')
  await sleep(3000)
  yield encoder.encode('{"text":"running"}')
  await sleep(3000)
  yield encoder.encode('{"text":"finished"}')
}

export async function GET() {
  const iterator = makeIterator()
  const stream = iteratorToStream(iterator)

  return new Response(stream, { headers: { "Content-Type": "text/plain" } })
}

https://nextjs.org/docs/app/building-your-application/routing/route-handlers#streaming

遅いAPIの準備ができました!

Streamの値からステータスを読み取るローディングUI

最終的にはこんな感じになりました。

// CustomSpinner.tsx
const statusToOffset = (status: StatusType) => {
    const diameter = 2 * RADIUS * Math.PI
    switch (status) {
      case Status.idle:
        return diameter
      case Status.called:
        return diameter * 0.8
      case Status.running:
        return diameter * 0.4
      case Status.finished:
        return 0
      default:
        return diameter
    }
  }

... 中略 ...

{isLoading && (
            <div className="my-2 relative">
            <>
              <svg className="-rotate-90" width={EDGE_SIZE} height={EDGE_SIZE} viewBox={`0 0 ${EDGE_SIZE} ${EDGE_SIZE}`}>
                <circle fill="transparent" cx={EDGE_SIZE * 0.5} cy={EDGE_SIZE * 0.5} r={RADIUS} strokeDasharray={2 * RADIUS * Math.PI} stroke="orange" strokeWidth={EDGE_SIZE * 0.1} strokeDashoffset={statusToOffset(status)} style={{ transition: 'stroke-dashoffset 0.6s ease-in-out' }} />
              </svg>
              <span className="absolute inset-0 flex items-center justify-center text-xs text-orange-500">{StatusToLabel[status]}</span>
            </>
            </div>
          )}

strokeDasharrayを円周と等しい値とし、strokeDashoffsetの値も円周と等しい値を初期値としています。この時点では、余白がバーを覆っているような状態です。この余白を徐々に減らして、transitionでバーを表現しています。視覚的にはバーが円状に延伸しているように見えますが、実際は余白が減っていくような動きになります。

また、-rotate-90にすることでバーの始点を円のトップに変更しています。デフォルトの始点は右端になります。

CustomSpinner.tsx 全文
"use client"

import { useState } from "react";

const EDGE_SIZE = 200
const RADIUS = 60

const Status = {
  idle: "idle",
  called: "called",
  running: "running",
  finished: "finished"
} as const

const StatusToLabel = {
  idle: "停止中",
  called: "リクエスト中...",
  running: "分析中...",
  finished: "完了💡"
} as const

type StatusType = typeof Status[keyof typeof Status]

const CustomSpinner = () => {
  const [isLoading, setIsLoading] = useState(false)
  const [status, setStatus] = useState<StatusType>("idle")

  const statusToOffset = (status: StatusType) => {
    const diameter = 2 * RADIUS * Math.PI
    switch (status) {
      case Status.idle:
        return diameter
      case Status.called:
        return diameter * 0.8
      case Status.running:
        return diameter * 0.4
      case Status.finished:
        return 0
      default:
        return diameter
    }
  }

  const onClick = async () => {
    setIsLoading(true)

    try {
      const res = await fetch("/api/sample")
      const reader = res.body?.getReader()

      if (!reader) return
      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        const text = new TextDecoder().decode(value)
        setStatus(JSON.parse(text).text)
      }
    } catch (error) {
      console.error(error)
    } finally {
      setTimeout(() => {
        setIsLoading(false)
        setStatus(Status.idle)
      }, 1000)
    }
  }

  return (
    <>
      <div className={`flex-col justify-center items-center`}>
        <div className="h-10">
          {!isLoading && <button type="button" className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={onClick}>Request</button>}
        </div>
        <div className="my-2 relative">
          {isLoading && (
            <>
              <svg className="-rotate-90" width={EDGE_SIZE} height={EDGE_SIZE} viewBox={`0 0 ${EDGE_SIZE} ${EDGE_SIZE}`}>
                <circle fill="transparent" cx={EDGE_SIZE * 0.5} cy={EDGE_SIZE * 0.5} r={RADIUS} strokeDasharray={2 * RADIUS * Math.PI} stroke="orange" strokeWidth={EDGE_SIZE * 0.1} strokeDashoffset={statusToOffset(status)} style={{ transition: 'stroke-dashoffset 0.6s ease-in-out' }} />
              </svg>
              <span className="absolute inset-0 flex items-center justify-center text-xs text-orange-500">{StatusToLabel[status]}</span>
            </>
          )}
        </div>
      </div >
    </>
  )
}
export default CustomSpinner;

より良い方法があれば教えてください〜

最後まで読んでいただきありがとうございます!
気ままにつぶやいているので、気軽にフォローをお願いします!🥺
https://twitter.com/Ryo54388667/status/1733434994016862256

Discussion