◆ props などに依存して state をリセットしたい
  ◆ ドラフト状態は内部の state で管理するエディタとか
  ◆ 編集するものが変わったときに state をリセット
◆ key だとコンポーネント使う側が設定しないといけない
  ◆ できればコンポーネント内部で解決したい
◆ useState だと変更を検出したときと set 後の 2 回レンダリングが必要
◆ 依存する値を設定できる版の useState 作っては見たけど 無難に key がいいような気がしてきた

React で useState にも useMemo や useEffect の第二引数で指定できる依存する値のリストを書きたいです

Editor コンポーネント

例えば input と button の 2 つだけがある Editor コンポーネントを考えます
編集中の値を親コンポーネントで管理して 一文字入力するごとに onChange を呼び出して OK ボタンが押されたら onClickOK を呼び出すことはできますが それだとほぼこのコンポーネントの存在意義がないです

const Editor = ({ value, onChange, onClickOK }) => {
return html`
<input value=${value} oninput=${(eve) => onChange(eve.target.value)} />
<button onclick=${() => onClickOK()}>OK</button>
`
}

編集中の状態でも別のコンポーネントで表示したいなどがあるなら これのメリットもあります
しかし編集中のドラフト状態がいらないのであれば 親コンポーネントで余計な処理が増えます
それに親コンポーネントに伝えることで 親コンポーネントが rerender されます
Editor 以外にも多くのサブコンポーネントがあればそれらすべてに rerender 処理が発生します
そういうことを考えると 編集が終わり確定したら親に伝わる形で十分です

const Editor = ({ value, onChange }) => {
const [draft, setDraft] = useState(value)
return html`
<input value=${draft} oninput=${(eve) => setDraft(eve.target.value)} />
<button onclick=${() => onChange(draft)}>OK</button>
`
}

これはうまく動きますが 後から value を更新できません
最初に state 化してそれ以降は value を使いません
確定するまでの編集中はずっと value は同じはずですが 別のテキストをこの Editor で編集することだってありえます

例えばこういう FOO, BAR, BAZ のボタンがあって押したボタンの内容を Editor で編集するページを作るとします

const App = () => {
const [texts, setTexts] = useState(["FOO", "BAR", "BAZ"])
const [active, setActive] = useState(0)

return html`
<div>
${
texts.map((t, i) =>
html`
<div key=${i}>
<button
style=${{color: i === active ? "red" : "black"}}
onclick=${()=> setActive(i)}
>
${t}
</button>
</div>
`
)
}
</div>
<${Editor}
value=${texts[active]}
onChange=${text => setTexts(texts.map((t, i) => i === active ? text : t))}
/>
`
}

デフォルトは FOO の編集状態ですが BAR を編集しようと BAR ボタンを押したとしても input のテキストは FOO のままです

value が変われば state を引数に渡す initialValue にしたいわけなので useEffect などの第二引数の挙動がほしいです

key

やりたいことは「state のリセット」になるので key を使えば実現は可能です
ただ key となると使う側が責任を持って指定しないといけないもので 忘れるとバグります

一応動かせる例

<!DOCTYPE html>

<script type="module">
import { html, render, useState, useMemo } from 'https://unpkg.com/htm/preact/standalone.module.js'

const Editor = ({ value, onChange }) => {
const [draft, setDraft] = useState(value)
return html`
<input value=${draft} oninput=${(eve) => setDraft(eve.target.value)} />
<button onclick=${() => onChange(draft)}>OK</button>
`
}

const App = () => {
const [texts, setTexts] = useState(["FOO", "BAR", "BAZ"])
const [active, setActive] = useState(0)

return html`
<div>
${
texts.map((t, i) =>
html`
<div key=${i}>
<button
style=${{color: i === active ? "red" : "black"}}
onclick=${()=> setActive(i)}
>
${t}
</button>
</div>
`
)
}
</div>
<${Editor}
key=${active}
value=${texts[active]}
onChange=${text => setTexts(texts.map((t, i) => i === active ? text : t))}
/>
`
}

render(html`<${App} />`, document.getElementById("root"))
</script>

<div id="root"></div>

使う側が key を指定しなくて済むほうが使いやすいですし key が違えば完全な作り直しになるので key を変えずに済むならそのほうが効率的です
編集対象の value だけで state を管理したいので コンポーネント内部で完結できないか考えてみます

custom useState

こういう useState ラッパーを作ってみました

const useState2 = (init, deps) => {
const [state, setState] = useState(init)
const sym = Symbol()
const changed = useMemo(() => sym, deps) === sym
return [changed ? init : state, setState]
}

useMemo を使って前回のレンダリング時から変化があったかを検知します
あった場合は useState で得られる state の代わりに init として渡された値を返します

使うときは 2 つめの引数に依存する値を指定するだけでこうなります

const Editor = ({ value, onChange }) => {
const [draft, setDraft] = useState2(value, [value])
return html`
<input value=${draft} oninput=${(eve) => setDraft(eve.target.value)} />
<button onclick=${() => onChange(draft)}>OK</button>
`
}

このフックはかなり便利かも!と思ったのですが……問題がありました
変化があった場合に setState はせずに単純に init を返してるだけです
state 自体は更新されていません
次に関数が呼び出される時 init を元に変更して setState を呼び出したのであれば state が更新されるので問題ありません
しかし 親コンポーネントのレンダリングや 別の state の変更が原因のレンダリングであれば 依存する値は変わりないので state の値をそのまま使おうとして 古い値を取得してしまいます

state を即時更新したいけど

state を更新できればいいのですが 即時に更新はできません
setState で設定したら もう一度レンダリング処理が起きるので そのときに変わってるというものです

const Component1 = ({ value }) => {
const [state, setState] = useState(value)

const changed = ...

if (changed) {
setState(value)
return
}

// something

return html`
<div>...</div>
`
}

1 回目は if 文に入り 2 回目は if 文に入りませんが state が value の値になっています
1 回目のレンダリング結果は不要で即 2 回目を行うので即時 return しています

これでとりあえずは動いたのですが 動かないケースもあってよくわからないので調べてみると非同期で更新するのが良いのだとか
非同期なら useEffect が使えるのですが hook の制限で if 文などの中に useEffect を書くと正しく動きません
しかし そうなると

useEffect(() => {
if (changed) {
setState(value)
}
})

if (changed) {
return
}

みたいに 2 回 changed によって分岐させないといけないですし 複雑になってきます

それに加えて useEffect は同期的に複数回レンダリングが発生した場合は 最後の 1 回しか実行されていませんでした
2 回レンダリングが起きた場合 1 回目が changed とみなされ 2 回目は changed にはなりません
呼び出されるのは 2 回目の useEffect だけなので setState が実行されません

同じコンポーネントが同期的に複数回レンダリングされることを避けるべきだとは思いますが ライブラリなども使って複雑になってくると意図せずそういうこともありそうですし StrictMode を使うと確実に 2 回実行されます
これは今後の並列モードのため?らしいですが 将来的に React 自体が複数回呼び出す可能性があるからそれのチェックとしているなら こういう useEffect の使い方は向いてなさそうです

useEffect をせず自分で非同期化します

if (changed) {
Promise.resolve().then(() => {
setState(value)
})
return
}

なんかいかにも React の仕様のための回避策感……

それに こうしても結局即時 state の更新ができないので 2 回呼び出されるムダは発生します
1 回目をレンダリングなしで早期 return しているので一瞬何も表示されない状態が発生するかもしれません

自作 hook で頑張る

やっぱりムダに 2 回レンダリング処理をしない方法がいいので useState2 の方向で考えてみます
そうは言っても変更があったときの変更を ref などに保存しておくしかなさそうです
そもそも完全に ref で state を管理して useState は要らないかも?と思いましたが ref は rerender を発生させないので画面の更新ができませんでした

既存の hook を組み合わせて使うのではなく 一から hook を作ってしまえばもっと簡単にできそうに思えます
完全な自作 hook はどう作るのかとドキュメントを見てみると 特にそれらしい記述はありません
ソースコードを見てみると Preact では hook を作るのに必要な関数は export されていなくてソースコードを書き換えでもしないと対応できなくなっていました
React もどうせそんなところでしょうし React と Preact で別に作らないといけなくなるなら あまりとりたい方法でもないです

結局 ref で頑張った結果こんな感じになりました

const useState3 = (init, deps) => {
const ref = useRef({})
const [, rerender] = useState({})
const sym = Symbol()
const changed = useMemo(() => sym, deps) === sym
if (changed) {
ref.current.value = init
}
ref.current.sym = sym

return [
ref.current.value,
v => {
if (ref.current.sym !== sym) return
ref.current.value = v
rerender({})
},
]
}

簡単なケースでしか動作確認してませんが一応は動いてます

まとめ

一応依存する値で state を更新できるようにはしてみましたが 無難に key を使っておくのがいいような気がしてきました
そもそも React 標準の input に defaultValue という機能があって それと同じような動きをするわけなので 変なことせずに同じように defaultValue という名前にして input と同じように扱えるほうが良さそうです
input の defaultValue の場合は key で変更を管理するので 自作のものも同じように key での管理です