きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

ReactRouter のページ遷移で値が切り替わるように Formik の入力値を localStorage に保存する

Formik のフォームの値を localStorage に保存しておくライブラリとして formik-persist というものがありますが、ReactRouter でページ遷移した際に値がうまく切り替わらなかったので自前で Hook を組んで何とか実装してみます。

経緯としては以下の通りです。

  1. formik-persist を使ったけどページ遷移しても遷移前のフォームの入力値が残り続ける
  2. せっかくなので localStorage を使って自前で処理を書いてみよう
  3. react-useuseLocalStorage を使ってみたけど key が変更されても値が切り替わらないので結局同じ問題が発生する
  4. 好みの挙動になるように自前で useLocalStorage Hook を実装しよう (˘꒳˘) 意識高い

もしかしたら key を適切に与えれば解消するような、react-router でよくある感じのものなのかもしれません。 動作するコードは以下のリポジトリに置いてあります。

useLocalStorage を定義する

useLocalStorage のコードは https://usehooks.com/useLocalStorage/ を始め色々見つかりますので参考にしつつ書いていきます。

import { useState, useEffect, useCallback } from 'react'

function getPersistedValue<V>(key: string, defaultValue: V) {
  const v = window.localStorage.getItem(key)
  if (!v) return defaultValue
  try {
    return JSON.parse(v) as V
  } catch (err) {
    console.warn(err)
    return defaultValue
  }
}

function persistValue<V>(key: string, value: V) {
  try {
    const v = JSON.stringify(value)
    window.localStorage.setItem(key, v)
  } catch (err) {
    console.warn(err)
  }
}

// Hook
export default function useLocalStorage<V>(key: string, initialValue: V) {
  const [value, setValue] = useState(() => getPersistedValue(key, initialValue))

  // `key` が変わったら値を再取得する (*˘꒳˘*) 
  useEffect(() => {
    setValue(getPersistedValue(key, initialValue))
  }, [key, setValue, initialValue])

  // 値を更新しつつ `localStorage` に保存する (*˘꒳˘*)
  const setValuePersistently = useCallback((v: V) => {
    persistValue(key, v)
    setValue(v)
  }, [key, setValue])

  // ちょっと色気を出してクリア用のメソッドも提供してみる (*˘꒳˘*)
  const clearValue = useCallback(() => {
    setValuePersistently(initialValue)
  }, [setValuePersistently, initialValue])

  return [value, setValuePersistently, clearValue] as const
}

今回の肝は key の変更に合わせて対応する localStorage の値で value を更新することと、関連するハンドラも忘れずに更新されるようにしておくことです。

Formik と合わせる

Formik と組み合わせて以下の動作を実現します。

  • initialValues が変更されたらフォームの内容を新しい initialValues でリセットする
  • onChange でフォームの値を localStorage に保存する
  • onSubmit でフォームの値を初期化する
    • 送信後コメントフォームを空にするというケースを想定
interface FormValue {
  title: string
  body: string
}

const createInitialValue = () => ({ title: '', body: '' })

const FormView: React.FC<RouteComponentProps<{ id: string }>> = props => {
  const { id } = props.match.params
  const formKey = `FormView:${id}`
  const initialValues = useMemo(createInitialValue, [])
  const [values, persistValues, clearValues] = useLocalStorage(
    formKey,
    initialValues
  )

  // `Formik` には `onChange` がないので `validate` で代用する (*˘꒳˘*) きたない
  const fakelyValidateToHandleChange = useCallback(
    (v: FormValue) => {
      persistValues(v)
      return Promise.resolve()
    },
    [persistValues]
  )

  return (
    <Formik
      initialValues={values}
      onSubmit={clearValues}
      validate={fakelyValidateToHandleChange}
      validateOnChange
      enableReinitialize
    >
      {() => (
        <Form>
          <Field name="title" component="input" />
          <Field name="body" component="textarea" />
          <button type="submit">Submit</button>
        </Form>
      )}
    </Formik>
  )
}

ポイントとしては以下の通りです。

  • enableReinitialize を指定して initialValues の変更時にフォームの値を新しい initialValues の値でリセットする
    • ページ遷移時に initialValues が変わってフォームの内容が遷移後のページのものに更新されるように
  • validateonChange として転用
    • 本当はこんなことやりたくないのですが...

残念なポイントとしては、上記のダーティハックはもちろんですが、せっかく Formik が State を管理してくれているのに、それをわざわざ親コンポーネントに引っ張り上げて二重管理をしてしまっている所です。 initialValues が実質 Controlled Component の value みたいな扱いになってしまっているので、ちょっと気持ち悪いです。 React でコンポーネントを組むときに、どうしようもない場合によく現れるパターンな気がしますが、スッキリしないですね。

とりあえず、一応動いたので今回は良しとします。気が向いたら formik-persist のコードも見ながら他にいい方法がないか調べてみます。

追記

以下のように FormikPersist 双方に key を設定すればページ遷移しても、適切に入力値が保存されたものに切り替わるようになりました。

const FormView: React.FC<RouteComponentProps<{ id: string }>> = props => {
  const { id } = props.match.params
  const formKey = `FormView:${id}`
  const initialValues = useMemo(createInitialValue, [])
  return (
    <Formik
      initialValues={initialValues}
      onSubmit={console.log}
      enableReinitialize
      key={formKey}
    >
      {() => (
        <Form>
          <Field name="title" component="input" />
          <Field name="body" component="textarea" />
          <button type="submit">Submit</button>
          <Persist name={formKey} key={formKey} />
        </Form>
      )}
    </Formik>
  )
}

これで一件落着したような気がします。react-persist は connect を使ってこんな風に Formik にアクセスできるんだなっていう良い例ですね。インスピレーションの幅が広がりそうです。