Reactでstateが絡まってきたのでuseReducerを使う

useReducerを使う

コメント消すツールに置換機能を追加しようと思ったときに、絡み合ったuseStateよりも1つのuseReducerを使った方がいいということに思い至ったので、使い所の参考として使用例を記録しておきます。

コメント消すツールはこちら:

Comment Replacer
正規表現でマッチした部分を削除するツール

これまでのuseState

置換機能の追加以前は3つのuseStateを使っていました:

function App() {
  // 左側の入力用のエリアの値を保持
  const [srcValue, setSrc] = useState("#some code here");
  // 右側の出力用のエリアの値を保持
  const [resultValue, setResult] = useState("result here");
  // マッチに使う正規表現の配列(RegExp[])を保持
  const [regexes, setRegexes] = useState(initialRegexes);
  // ...
}

設定を保持する意図で使っていて、setSrconChangeで、setRegexesは設定画面の保存ボタンを押したときに動作するようにしていて、setResultsetSrcの直後に使っていました。このように:

function App() {
  // ...
  return (
    <textarea id="source" onChange={e => {
      setSrc(e.target.value);
      let resultstr = e.target.value;
      for (const re of regexes) {
        resultstr = resultstr.replaceAll(re, '');
      }
      // 加工した結果をセット
      setResult(resultstr);
    }} value={srcValue}></textarea>
  );
}

この段階ではマッチ部分は空文字に置換するようにしていました。そして置換後にsetResultを使用していました。

このように使っていると、入力用のテキストエリアに文字列を打ち込むことでしかsetResultを走らせることができません。

この仕様は置換機能を追加するときに動作の不自然さを感じるような気がするので修正する必要があります。(正直にいうと、現状でも正規表現を編集してもすぐに反映されないという問題はあります。)

ということで設定変更の反映に一旦入力が必要になる問題を修正します。

単純にstateでは不自然

まずはうまくいっていない例から。(お急ぎの場合はスキップしてください。目次からの移動が便利です。)

最初は、置換に使う文字列用のstateを保持して、replaceAllでその文字列を使用するようにしていました。このように:

function App(){
  // ...
  // 置換文字列用のstateを追加
  const [substitutionString, setSubstitutionString] = useState(storedSubstitutionString);
  // ...
}

そして置換文字列はinput[type=text]な要素から入力し、onChangeで随時stateで保持することにしていました:

function App() {
  // ...
  return (
    <input type="text" id="substitution-str" placeholder="置換する場合はここに置換先テキストを入力…"
      onChange={e => {
        setSubstitutionString(e.target.value);
      }}
      value={substitutionString}
    />
  );
}

当然このままでは置換文字列を入力しただけでは出力の文字列は更新されないので、setSubstitutionString()のあとにreplaceAllしてsetResult()を実行したくなります。しかしこのままでは置換文字列が即時には反映されません。これは、stateへのsetは非同期的に実行されるため、setSubstitutionString()してもreplaceAllするときの値は、直前のsetSubstitutionString()がまだ反映されておらず、前回の値のままであることが原因です。そのため1つ前の状態の置換文字列が出力に反映されてしまいます。

一応stateでも何とかなる

useStateのままでもなんとかなる方法も書いておきたいです。お急ぎなら読み飛ばして下さい。

useStateではobjectも保持できるので、このように、先程の4つのstateを1つのオブジェクトとして保持すれば一応は何とかなります:

function App() {
  const [state, setState] = useState({
    src: 'initialSrcText',
    result: 'initialResultHere',
    regexes: initialRegexes,
    substitutionString: 'initialSubstitutionString'
  });
  // ...
}

しかし、更新時の処理がコンポーネントのレンダーの中で何度も現れてしまうのは好ましくありません。 それぞれのonChangeonClickで似たような微妙に異なる出力の生成を何度も行うのは、記述が長くなり直観的に分かりづらいと思います:

function App() {
  // ...
  return (
    <textarea id="source" onChange={e => {
      // updateResultは別で必要になる。
      const result = updateResult(e.target.value, state.regexes, state.substitutionString);
      setState({...state, src: e.target.value, result: result});
    }} value={state.src}></textarea>
  );
}

このsrcの部分や、updateResult()に渡す引数が微妙に入れ替わるだけなのに、レンダー部分に頻出してくるのは非常に見辛いと思うので今回は使用しませんでした。

今回の場合ではuseStateでオブジェクトを持つのは相応しくないですが、そういうこともできるというのは知っておくと便利そうです。

useReducerを使う

useReducer(reducer, initialState)は呼び出すと、statedispatchが返ってきて、stateの更新をdispatch()で行える関数です。

dispatchにはオブジェクトを渡すことができ、dispatch(obj)すると渡したオブジェクトはreducerに渡ります。 reducer(state, obj)は自分で定義する関数で、先ほどの渡されたオブジェクトをもとに新しいstateを生成して返すことでstateの更新を行えます。

reducer関数に更新処理をまとめられるので、関連性の高いstateの更新処理の見通しが良くなると思います。 コンポーネントのonChangeなどもdispatchを呼び出すだけなので、レンダー部分が簡潔になります。

こんな感じで使用します:

// ...

const initialState = {
  src: "#some code here\nprint('HELLO WORLD')",
  result: 'result here',
  regexes: initialRegexes,
  substitutionString: ''
};

function reducer(state, action) {
  // actionに応じた新しいstateを返す
  const updateResult = (src, regexes, substitutionString) => {
    let resultstr = src;
    for (const re of regexes) {
      resultstr = resultstr.replaceAll(re, substitutionString);
    }
    return resultstr;
  };
  switch (action.type) {
    case 'changeSrc':
      return {
        ...state,
        src: action.src,
        result: updateResult(action.src, state.regexes, state.substitutionString),
      };
    case 'changeRegexes':
      return {
        ...state,
        regexes: action.regexes,
        result: updateResult(state.src, action.regexes, state.substitutionString),
      };
    case 'changeSubstitutionString':
      return {
        ...state,
        substitutionString: action.substitutionString,
        result: updateResult(state.src, state.regexes, action.substitutionString),
      };
    // ...
    default:
      throw new Error('undefined action type was assigned');
  }
}

function App() {
  const [editor, dispatch] = useReducer(reducer, initialState);
  // ...
  return (
    // ...
    <textarea id="source"
      onChange={e => dispatch({ type: "changeSrc", src: e.target.value })}
      value={editor.src}></textarea>
  );
}

reducerの定義は必要ですが、コンポーネントのレンダーにてonChangeがとてもすっきりするのがわかります。先ほどのと同じくらいの記述量で出力結果の更新を行えます。 dispatchに種類と必要な値を渡すだけなのでここまで簡潔にできます。

reducerでのstateの更新では、更新しない値もreturnに含めるのを忘れないようにしましょう。...stateを最初に含めて、後続に更新するものを追記して上書きするのが良い方法だそうです。

おわり

stateが絡み合ってきたらオブジェクトを利用するか、useReducerを使うか、というようなことも考えておきたいと思います。

レンダー部分に書く処理が長くなるようならuseReducer()を使うように心掛けるようにしたいです。一方で2つセットで扱うくらいのもの(座標など)ならオブジェクトを利用していきたいと思います。

結構公式(下記参照)のと被ってしまうことを書いてしまったけど、使い分けについては考えることができたのでよしとしたいです。

Hooks API Reference – React
A JavaScript library for building user interfaces

コードのハイライトが一部不正確になっているのを修正できなくて残念です… ...stateとかを入れているせいかな。

以上です。


Amazonアソシエイト

https://amzn.to/3Ju3o0t

コメント

タイトルとURLをコピーしました