React で１文字ずつ出すUIをhooksで実装する

## １文字ずつ出すUI

Reactで実装していると、こういう１文字ずつ表示されるUIが欲しくなる時ありますよね？

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92647/b1bd6d2c-b706-a16d-f8bc-f2cf0624636e.gif)

ただ、Yahoo（実質Google）で調べた限りだと、よく知らないJavaScriptの標準機能やよく分からないライブラリを使った結果しか自分は観測できませんでした（自分の検索能力が足りないだけかもしれません）。

![スクリーンショット 2024-12-08 10.22.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92647/9764ae1c-857d-28d3-8986-b2710a3d5a92.png)

ChatGPT に聞いた感じでも、
https://chatgpt.com/share/6754f58d-ce98-8006-bcac-14a039779bf8
みた感じ、undefined が表示されるなど、少し挙動が変に見えました。

![output_3s.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/92647/1e7e91c2-0c8a-6895-5e84-cbbe5aa66f30.gif)

https://reactplayground.vercel.app/#N4IgLgziBcBmCGAbCBTANCAbrK1QEsA7AExQA8A6AK1xHwFsAHAewCcwACAQUcY9lbN6HAOQUA9D0bUIZEQB1CDFuw4AlFPADGnAUNGtNOhUqZtOwDlsPwwKNc2acAvv0HCRNnQFpiQ8VqI+CiEYCaKWsyEEJyRofBEKKwcALwcfloArvQhYBQA5ihgAKKIKDmhAEIAngCSxAAUno5hAJQRUTEcgk6pVjZ2Dk4NcWAJhEnthD15hiRJDYoc6kZ51pp2peW5i4TLyxraeQDKYKz4OgCyzKRoS-uEmYiId3v7hzoU67YoWxVgDSkU2WU1aIAwUhkZBgdDMqg+YDQHEsmVQpx+SNRv1gsBQOg4rj0Hi8YQA3IoOtFOAAVaqMIj5Yo4vGcNINSx2MiIjgQRgoFDEAmtVIAPmR9ziXQA2sR8LzEPBqgLqeRuagwAAROWMBVK4gqrkAXT6WPRdiaIla5MI9yxTNxOgaDWFKTFwHuyzKnCIpDIfQADNaPVZOt7QklMEh6n11bVw6xI4gnS63cHlvhYBwGj7yBwADwcTl5MqEfJgAAWwvdb32+3VWvliuVqqdjEMmBTHDbKEwHAA1IXVVKc2RDVa0-sR32+9ba8tXChkChxTW54FNKw43YE0hs-HE-Vx6v58HnEjefziEfg+JxBxAPUMgCuGQA-DIBnhkARQyAYYZAOsMgCLUwAOpoA5gyAP0MgAlDIAewzPoASQxPu+waGGAmSsHszqilYZTwJu+67kQ24Hles4EkiUpFuefICmOQZvAhSF7HmsqYCKwCyo2eoGmAzh5uIDEitazhUZKnBSH0qGuiuyw0chWbBvR+CMROea0vSpb2iyg5cik8ggAAEouiDMEiADqbCIMQACEWk8uRxApMAACM-r+q44gijJ3Fya5bxHvxFITGQKicKQCBPEJvDWiAzjOEAA

なので、自分で実装してみました。

## いざ実装

今回は仕様上、３つの文字列を、ループで１文字ずつ表示させるようにしました。成果物はこちらをご覧になってください。

https://coffeecup.jp/lp3

まずは、表示させたい文字列とオリジナルの全文の文字列をuseStateで、文字列の位置を useRef で定義します。

※最初は、useRef で定義せず実装せず上手くいきませんでした。ここら辺の知見がある方がいたら教えてくださると嬉しいです。

```typescript
    const originalFirstContent = "記事：HTMXの正体が分からないので、オレオレHTMXを作ってみた"
    const [firstContent, setFirstContent] = useState("")
    const originalSecondContent = "記事：React に プルリクを送ったけど、マージされなかった話"
    const [secondContent, setSecondContent] = useState("")
    const originalThirdContent = "記事：4つ目のPRでようやく Next.js にコントリビュートできた話"
    const [thirdContent, setThirdContent] = useState("")
    const currentPos = useRef(0)
```

次に、useEffect で、１文字ずつuseStataを更新するロジックを書きます。
流れは、以下の感じです。

1. setIntervalを作る
2. 1で作ったsetIntervalで最初に文字列の初期化を行う
3. １文字ずつ更新する処理をpromise & sleep で実装する
4. 3で作ったpromiseをPromise.allで実行する

では、やってみましょう！

#### １：setIntervalを作る

ここは簡単です。setInterval は setTimeout のループ版みたいなやつです。

```typescript
    useEffect(() => {
        async function doType() {
            // ここに実装を書く
        }
        doType()
        const id = setInterval(async() => {
            doType()
        }, 5000)
        return () => {
            clearInterval(id)
        }
    }, [])
```

この doType は setInterval だと初期発火まで５秒待たないといかないので、useEffectのなかで１回呼び出しています。

#### ２：1で作ったsetIntervalで最初に文字列の初期化を行う

１文字ずつ表示し終わった時に、文字をリセットする必要があるので、リセットの処理を行います。

```diff_typescript
    useEffect(() => {
        async function doType() {
+           setFirstContent("")
+           setSecondContent("")
+           setThirdContent("")
+           currentPos.current = 0
        }
        doType()
        const id = setInterval(async() => {
            doType()
        }, 5000)
        return () => {
            clearInterval(id)
        }
    }, [])
```

#### ３：１文字ずつ更新する処理をpromise & sleep で実装する

sleep処理をいくつか加えている部分もありますが、いかが大体のコードになります。（無駄があったらすみません！）
基本的には、promiseのsleepをタイマーにして、その後のthenで更新をしている感じです。

```diff_typescript
        async function doType() {
            setFirstContent("")
            setSecondContent("")
            setThirdContent("")
            currentPos.current = 0
+           await new Promise((resolve) => setTimeout(resolve, 300))
+           const promises: (Promise<void>)[] = []
+           for(let i = 0; i < originalThirdContent.length; i++) {
+               const promise = new Promise<void>((resolve) => {
+                   setTimeout(resolve, i * 100)
+               }).then(() => {
+                   currentPos.current = currentPos.current + 1
+                   setFirstContent(originalFirstContent.slice(0, currentPos.current))
+                   setSecondContent(originalSecondContent.slice(0, currentPos.current))
+                   setThirdContent(originalThirdContent.slice(0, currentPos.current))
+               })
+               promises.push(promise)
+           }
        }
```

#### ４：3で作ったpromiseをPromise.allで実行する

最後にpromiseを実行します。

```diff_typescript
        async function doType() {
            // ... 省略
                promises.push(promise)
            }
+           Promise.allSettled(promises)
+           await new Promise((resolve) => setTimeout(resolve, 1000))
        }
```

これでロジックは実装できました！

<details><summary>ロジックの全コード</summary>

```typescript
    const originalFirstContent = "記事：HTMXの正体が分からないので、オレオレHTMXを作ってみた"
    const [firstContent, setFirstContent] = useState("")
    const originalSecondContent = "記事：React に プルリクを送ったけど、マージされなかった話"
    const [secondContent, setSecondContent] = useState("")
    const originalThirdContent = "記事：4つ目のPRでようやく Next.js にコントリビュートできた話"
    const [thirdContent, setThirdContent] = useState("")
    const currentPos = useRef(0)
    useEffect(() => {
        async function doType() {
            setFirstContent("")
            setSecondContent("")
            setThirdContent("")
            currentPos.current = 0
            await new Promise((resolve) => setTimeout(resolve, 300))
            const promises: (Promise<void>)[] = []
            for(let i = 0; i < originalThirdContent.length; i++) {
                const promise = new Promise<void>((resolve) => {
                    setTimeout(resolve, i * 100)
                }).then(() => {
                    currentPos.current = currentPos.current + 1
                    setFirstContent(originalFirstContent.slice(0, currentPos.current))
                    setSecondContent(originalSecondContent.slice(0, currentPos.current))
                    setThirdContent(originalThirdContent.slice(0, currentPos.current))
                })
                promises.push(promise)
            }
            Promise.allSettled(promises)
            await new Promise((resolve) => setTimeout(resolve, 1000))
        }
        doType()
        const id = setInterval(async() => {
            doType()
        }, 5000)
        return () => {
            clearInterval(id)
        }
    }, [])
```
</details>

表示部分は、以下のようにすれば表示されるはずです。

```typescript
{ firstContent && (
    <span
        style={{color: "red"}}
    >
        <br/>{firstContent}
    </span>
)}
```

react の hooks だけで実装したい人の役に立てば幸いです。

## 最後に

個人開発で検索エンジンを作っています！
よかったらみて見てください！

https://coffeecup.jp/lp2

https://coffeecup.jp/lp3