◆ イベント処理時に非同期処理を挟んで setState する場合
◆ state の一部のみの更新で state と差分をマージする方法だと state が変化しないので古い状態に戻る
◆ 関数を渡して最新の state とマージさせる
◆ useReducer を使ってマージさせる reducer を設定しておくと楽かも

useState を使って state を保持して 複数のプロパティがある場合

setState({ ...state, prop: value })

形式での更新が多いです
a と b のプロパティをもつ state で b はボタンが押されたら即更新して a はその後非同期処理をしてから更新したいという場合

import React, { useState } from "react"

export default function App() {
const [state, setState] = useState({ a: 1, b: 1 })

const onClick = async () => {
setState({ ...state, b: 2 })
await new Promise(r => setTimeout(r, 1000))
setState({ ...state, a: 2 })
}

return (
<div onClick={onClick}>{JSON.stringify(state)}</div>
)
}

と単純にやるとうまく動きません

{"a":1,"b":1} // 初期状態
{"a":1,"b":2} // ボタン押した直後
{"a":2,"b":1} // 1 秒後

という結果になります
最初これで困ったのですが よく考えると当たり前です
これは state が {a:1,b:1} のままなのでそれと結合したら b は 1 に戻ります

ほとんどの場合は state を更新したら再レンダリングして そのときの state からの更新になるのでこういう問題は起きません
しかし非同期処理が入ってくるとこういう場合が発生します
引数に直接渡さず変数に保持してから引数に渡して 次は前回変数に保持したものと結合すればうまく動きます

let value = { ...state, b: 2 }
setState(value)
await asyncFn()
value = { ...value, a: 2 }
setState(value)

連続で何回かの非同期関数を呼び出してその合間に毎回 state を更新する場合は少し面倒になります

もう少しきれいに書ける方法を考えていると useState の更新関数には関数も渡せました
引数に現在の state を受け取ります
つまり そのスコープの state ではなく引数の state を使ってこうすればよいわけです

import React, { useState } from "react"

export default function App() {
const [state, setState] = useState({ a: 1, b: 1 })

const onClick = async () => {
setState(state => ({ ...state, b: 2 }))
await new Promise(r => setTimeout(r, 1000))
setState(state => ({ ...state, a: 2 }))
}

return (
<div onClick={onClick}>{JSON.stringify(state)}</div>
)
}

ですが これ 更新ごとに毎回書くのが面倒です
更新の度に書くのではなく定義する段階で更新方法を指定できたら……と思っていたら useReducer がありました
Redux 風に type プロパティ付きのオブジェクトをアクションとして受け取って type で分岐して更新処理 みたいな使い方がよくされてますが それは使い方のひとつであってそうしないといけないものでもないです
reducer にオブジェクトをマージする処理を指定しておけばこう書けます

import React, { useReducer } from "react"

export default function App() {
const [state, mergeState] = useReducer((s1, s2) => ({ ...s1, ...s2}), { a: 1, b: 1 })

const onClick = async () => {
mergeState({ b: 2 })
await new Promise(r => setTimeout(r, 1000))
mergeState({ a: 2 })
}

return (
<div onClick={onClick}>{JSON.stringify(state)}</div>
)
}

すっきりしていい感じです
ただ useReducer としては変わった使い方だと思いますし reducer 関数は毎回一緒です
新しいフックとして別の名前にしたほうがいいかもしれませんね

const useMergeState = (initial_value) => {
return useReducer((a, b) => ({ ...a, ...b }), initial_value)
}