⛓️

詳解:フロントエンドの状態とリアクティブ (なぜuseEffect()でsetState()がアンチパターンか)

2024/12/23に公開

すべての状態をできるだけ減らしたいypresto (プレスト) です。

12月頭に予約してたアドベントカレンダーですが12/23になってしまいました。 LayerXのバクラク事業部では、Webフロントエンド領域もがんばっています!! ということで一筆。

バクラク事業部のエンジニアは、バックエンド・フロントエンドの垣根なくプロダクト開発を手掛けています。各々に得意領域があり、わたしはフロントエンドの改善やコードレビューなども行っています。

そのコードレビューで、「Vueの watch() を使用せずに computed() でリアクティブに書きたいです」 (Reactで言えば useEffect() を避けたい) と指摘させていただいたときに、理解を深めたいとコメントを頂いたこともあり、フロントエンド開発のコアとも言える、リアクティブ (Reactive) な状態管理の話をまとめようと思います。(※ "リアクティブ" の "定義" については触れません)

// Not good: React
const [users, setUsers] = useState([])
const { data, isLoaded } = useUsers()
useEffect(() => {
    setUsers(data.map(...))
}, [isLoaded])

// Not good: Vue
const users = ref([])
const { data, isLoaded } = useUsers()
watch(isLoaded, () => {
    users.value = data.map(...)
})

TL;DR:状態や副作用が増えるほど複雑でデバッグも大変になり、それに抗ってきたのがリアクティブという理解です。上記のusersに正しい値が入っているかは、 useEffect() / watch() の条件と中身を見ないといけないですが、 useEffect() / watch() を使わず記述すれば常に正しい値になることが保証され安心できます。

フロントエンドとはステートフルだからこそ難しい

Webアプリのバックエンド開発では、状態の保存には主にデータベースなどの外部の独立したコンポーネントを利用し、アプリケーション自体は原則としてステートレスに実装します。 (図ではわかりやすいよう「API」と表現しました)

一方でフロントエンド開発ではアプリケーション自体がフォームの入力値などのUIの状態を持ちながら、ユーザーやサーバーと対話することになります。状態が格納されるのはDOMであったり、ReactやVueのコンポーネントであったりするかもしれませんが、何らかの形でステートフルです。

計算で得られる状態 (Derived State)

UIに関わるアプリケーションでは、ある状態から別の状態を計算する操作を多数行います。「フォームUIの表示内容」も、「ユーザーの入力値」という状態から計算されると考えることができます。このような状態間の接続は、注意深くコーディングしないとしばしばバグの原因になり、ステートフルがゆえの難しさとなっています。例えば、下記ようなフォームを考えてみます。

  • 記事の投稿画面
    • 本文
    • 予約投稿のチェックボックス
    • 投稿日時の入力欄
    • 投稿ボタン
  • 投稿ボタンのdisabled状態は、下記の状態から計算される
    • 本文が空の間のときはdisabled
    • 予約投稿がオンでかつ、投稿日時が空の間はdisabled

画面例

状態間の関係

これをReactなどを使わず素朴で手続き的なJSで表現すると、例えば下記のようになるでしょう (class名などは適宜)。

let bodyField = document.querySelector('.body-field')
let scheduleCheckbox = document.querySelector('.schedule-checkbox')
let dateField = document.querySelector('.date-field')
let submitButton = document.querySelector('.submit-button')

function updateSubmitDisabled() {
    submitButton.disabled = (
        bodyField.value.length === 0 ||
        (scheduleCheckbox.checked && dateField.value.length === 0)
    )
}

const onBodyChanged = () => { updateSubmitDisabled() }
const onCheckboxChanged = () => { updateSubmitDisabled() }
const onDateChanged = () => { updateSubmitDisabled() }
// addEventListener()は省略

この実装方法では、機能の拡張のたびに下記の変更をする必要があります。

  • 「送信中はdisabledにしたい」のように、依存する状態が増えるたびに、イベントハンドラ onXxxChanged() を追加
  • 「入力文字数を表示したい」のように、UI更新対象が増えるたびに、イベントハンドラに updateXxx() の呼び出しを追加

このように、他の状態から計算される状態を手続き的に更新すると、状態間の同期の「タイミング」と「更新対象」の網羅に気を使う必要があり、コードの見通しが悪くなったり、バグの原因になります。

パラダイムシフト:手続きからリアクティブへ

リアクティブを標榜する各種のライブラリ等はこのような課題を、手続きによる更新に代えて「状態の依存関係や計算方法」を宣言し、必要なタイミングで自動的に再計算することで解決してきました。この方法であれば、常に最新の正しい値が使われるという安心がもたらされます。

先の例に挙げた投稿ボタンのdisabled状態は、下記のような 純粋関数として表現できます。(純粋関数は、副作用のない関数=引数で渡されていない状態の参照も変更もしない関数です。数学の世界の関数 f(x) をイメージしてください。)

function disabled(body, scheduled, date) {
    return body.length === 0 || (scheduled && date.length === 0)
}

例えばReactiveXの実装の1つであるRxJSでは、このことを用いて下記のようにUIを実装できるでしょう。

import { combineLatest } from 'rxjs';

// bodyFieldValueなど、DOMをストリームに変換する記述は省略

// 計算で得られる状態を定義
const disabled =
    combineLatest([ // 計算に使用する状態を宣言
        bodyFieldValue,
        scheduleCheckboxChecked,
        dateFieldValue,
    ])
    .map(([body, scheduled, date]) => { // 3つの状態のいずれかが更新されるたびに実行
        return body.length === 0 || (scheduled && date.length === 0)
    })

// Viewに接続
disabled.subscribe(value => { submitButton.disabled = value })

ここで変数 disabled の中身は「ストリーム」を表現したオブジェクトで、依存している状態が変更されるたびに最新の計算結果が「流れて」くる、ホースや蛇口のようなものです。その中身は subscribe() すると取り出すことができ、投稿ボタンの状態にセットしています。このセット操作は冪等 (何度実行しても同じ結果になる) に記述することに注意します。

ロジックが純粋関数であり、Viewへの反映が冪等であることで、関連する状態が変わった際に、いつでも何度でも・単一のロジックで、Viewの更新操作が安全に実行できます。この前提があることで、複数の入力値とUIの、状態間の同期管理をRxJSに任せることができ、RxJSは必要なときに自動で map() に書かれた関数を呼び出します。このように、リアクティビティ (Reactivity) は複数の状態を協調させるための「シートベルト」として機能します。

リアクティブな「ストリーム」の発展と課題

リアクティブのパラダイムは、Microsoftによって開発されたReactiveX (Rx) を皮切りに、Android・iOSアプリの開発や、宣言的UIにも導入されていきました。

一方で、RxJSをはじめとする「ストリーム」または「Observerパターン」のデザインを踏襲しているライブラリでは、UIをはじめとする状態のストリームへの変換、専用の命令群を通したストリームの変換といった、ホースを繋ぎ合わせるようなコーディングへの理解が必要です。実際、Rxの変換操作は数えられないほどあります。

CombineLatestの解説図
ReactiveXのドキュメント にある、CombineLatestの動作を解説する図。

このため素のJSとは異なる、ライブラリ特有の使い方やクセ、デバッグ方法に対応するための学習・実装コストが発生していました。

React hooksのデザイン

Reactでは下記のルールとすることで、ストリームを使わず「普通のJavaScriptの関数」をリアクティブにしています。

  • コンポーネント関数は、hooksの呼び出し以外は、純粋関数として宣言する
  • 外部から渡される状態 (props) だけでなく、hooksで参照する状態 (useState()など) が変わるたびに、コンポーネント関数を再実行する
  • VirtualDOMを使用し、DOMへの結果反映を冪等にする

stateを参照するhooksの結果も、純粋関数で記述されたロジックの引数の1つのように扱われます。すなわち、Reactのコンポーネント関数は次の式のように考えることができます。

VirtualDOM = f(props, hooks1, hooks2, ...)

これにより、関数コンポーネント本体に直接、局所的なstateの宣言 (useState())や、外部状態の参照 (Reduxや通信用のhooksの呼び出しなど) を記述できるため、リアクティブのメリットの享受と同時に、記述の簡潔さと凝集性の向上に寄与しています。

function ArticleForm() {
    const [body, setBody] = useState("")
    const [scheduled, setScheduled] = useState(false)
    const [date, setDate] = useState("")

    const disabled = (
        body.length === 0 ||
        (scheduled && date.length === 0)
    )

    return (
        ...
        <Button disabled={disabled}>投稿する</Button>
        ...
    )
}

余談:VueのComposition APIのデザイン

Vueの場合は Ref が「ストリーム」相当の仕組みとなっており、 computed(() => counterRef.value + 1) のように .value をつけて参照するだけで自動で再計算が実行されるます。専用命令を使わずにストリームを構築できるデザインとなっており、Rx等に比べると学習コストが低いと言えます。一方でデバッグがReactほど容易でないことが弱点と言えます。 (わたしは必要なときは watch()debugger を書いています)

ベストプラクティス:計算される値は、状態ではなく式として表現する

記事冒頭で挙げたコードを振り返ると、watch()useEffect() を使ってその場で状態を更新していました。状態間の依存関係の更新を、手書きで、手続き的に行うものでした。一方で、Betterのように countB の更新タイミングをリアクティビティに委ねることで、常に正しい値が得られることが保証されます。

// Not good
const [users, setUsers] = useState([])
const { data, isLoaded } = useUsers()
useEffect(() => {
    setUsers(data.map(...))
}, [isLoaded])

// Better
const { data, isLoaded } = useUsers()
const users = useMemo(data.map(...), [data])
// Not good
const users = ref([])
const { data, isLoaded } = useUsers()
watch(isLoaded, () => {
    users.value = data.map(...)
})

// Better
const { data, isLoaded } = useUsers()
const users = computed(() => data.map(...))

例えばprops、React.ContextやReduxのような状態管理、Next.js/React RouterのURL管理、Apollo/SWR/TanStack Queryのリクエスト管理など、あらゆる「外界」の情報も、hooksやRefにすることでリアクティブに取り扱えます。アプリケーション開発者としては、React、またはVueのRefに現れる すべての値 は、リアクティブに参照するべきです。

Apolloに onResult() のように、リアクティブでないcallbackがサポートされていることがあるかもしれませんが、これは後述する特別な場合にのみ使いましょう。

// Not good
const { onResult } = useQuery(...) // ApolloでAPIに問い合わせ
onResult(result => {
  setUsers(result.data?.users.map(...) ?? [])
})

// Better
const { result } = useQuery(...) // ApolloでAPIに問い合わせ
const users = result.value?.users.map(...) ?? []

URLとRouterの具体例:検索フォームの入力とURLを同期

例えばNext.jsで検索フォームの検索キーワードをリロードしても残したいとします。下記の実装例では、状態「search」を用意し、URLとの間で useEffect() による手動の同期を行っています。

// Not good
const router = useRouter()
const searchParams = useSearchParams()
const [search, setSearch] = useState(searchParams.get("search"))

useEffect(() => {
    router.replace(`/foo/bar?search=${search}`)
}. [search])

return (
    <input value={search} onChange={e => setSearch(e.target.value)} />
)

しかし searchParams 自体をリアクティブに扱えます。この場合、searchParamsないしURLを "Single Source of Truth" として扱うことで、 useState() を削除することができます。

// Better
const router = useRouter()
const searchParams = useSearchParams()

const setSearch = (newSearch) => {
  router.replace(`/foo/bar?search=${newSearch}`)
}

return (
    <input value={searchParams.get("search")} onChange={e => setSearch(e.target.value)} />
)

こちらのようなライブラリを使って実現する方法もありそうです。
https://github.com/47ng/nuqs

複雑な処理や複数の結果を返す場合の具体例

あるときレビューしたコードでは、レスポンスの内容に他の状態も組みわせて、複数の辞書 (map) を構築するコードを watch() から起動していました。いくつもの変数が関わる計算の場合は、forループで手続き的に書いたほうが見通しもパフォーマンスも良いこともあるのは事実です (Go言語はこの文化ですよね)。

const [userMap, setUserMap] = useState({})
const [groupNameMap, setGroupNameMap] = useState({})

useEffect(() => { // Vueではwatchに読み替えてください
    const userMap = {}
    const groupNameMap = {}
    for (const user of result.data.users) {
        ...
    }
    setUserMap(userMap)
    setGroupNameMap(groupNameMap)
}, [result.data.users])

ここでは setState() を手続き的に呼び出すのは避けつつ、リアクティブにおける「純粋関数」の中身では手続き的に記述することができます。(テストのためにも、関数を切り出すことをおすすめします)

const { userMap, groupNameMap } = useMemo(() => { // Vueではcomputedに読み替えてください
    const userMap = {}
    const groupNameMap = {}
    for (const user of result.data.users) {
        ...
    }
    return { userMap, groupNameMap }
}, [result.data.users])

手続き的な方がシンプルな場合

計算できる状態が useState() になっているのは基本はアンチパターンですが、初期値だけが他の状態から計算されるだけで、別途独立した状態が必要なケースもあります。最たる例はフォームの初期値です。

  • ダイアログを開いたときに、propsから渡された初期値をフォームにセットする
  • APIから返された結果をフォームに初期値としてセットする

このように、状態を別で用意する必要がある場合はわずかに存在するため、見極めが必要です。

状態の更新の仕方を見直す

「ダイアログが開いたとき」に状態を更新するためには、propsの変化を監視することになります。Vueでは watch() がその役割を果たしますが、Reactでは (ESLintの react-hooks/exhaustive-dep ルールで) useEffect() に渡す配列に参照する値の一部を削ったり、参照してない値を入れることでwatchとして使うことを実質禁止しています。

useEffect(() => {
    if (open) {
        setState(props.initialValue)
    }
}, [open]) // React Hook useCallback has a missing dependency: 'props.initialValue'

これについては、公式ドキュメントで理想的な書き方が紹介されているので、ぜひご一読ください。
https://ja.react.dev/learn/you-might-not-need-an-effect

(ちなみにコンポーネント関数内 (useEffect() の外) で直接 setState() を呼び出すことを推奨している箇所があり、わたしはちょっと驚きました。)

react-hook-form

react-hook-form や Redux など、それ自体に状態を持っているライブラリの場合、APIリクエストの結果を反映したい場合に手続き的な setValues()dispatch() などを使わざるを得ないですよね。妙案がある方はぜひコメントいただければ思います。🙏

まとめ

  • フロントエンドはステートフルとの仁義なき戦いで、リアクティブはそれを簡潔・安全に記述する仕組み
  • 状態間の関係を宣言することを意識し、手動で管理する状態を減らせば、バグの入る余地も減る
  • 他の状態から計算される値には、絶対に!状態 (useState()) を作らない
  • URLやリクエストのような外界情報も、hooksに閉じ込められればリアクティブに扱える

長くなりましたが最後まで読んでいただきありがとうございます。 リアクティブというシートベルトを必ず締める ことで、状態間の関係が複雑になりがちなWebフロントエンドが、より安全に実装できることが伝わったならば幸いです。

参考資料

https://ja.react.dev/learn/you-might-not-need-an-effect
https://ja.react.dev/learn/reacting-to-input-with-state
https://speakerdeck.com/uenitty/why-declarative-ui-is-less-fragile

宣伝

LayerX では「大 Casual Night for Dev 〜 新年会 〜」をまだまだ募集しています!
https://jobs.layerx.co.jp/dai-casual-night

わたしとのカジュアル面談はこちら!
https://jobs.layerx.co.jp/164cdd370bae8062a550c8038970ade1

LayerX

Discussion