1
1

More than 1 year has passed since last update.

useReducerでつまづいたことを調べたらJSとReactの知識がついた

Posted at

はじめに

ポートフォリオ作成でReactを使用しています。

フォーム画面で useReducer を使用していたところ、階層を持つオブジェクトについてうまく扱えませんでした。
具体的には一つの関数内で複数回dispatchを用いてオブジェクトを更新した場合、階層の深いものについて更新漏れがありました。

そこで調べてみると、Reactの仕組み、及びそもそものJSの仕組みについて自分の中で得るものがあったのでその備忘録です。

説明用のコードは以下にあります。
https://codesandbox.io/s/blissful-sanderson-sm3ccf?file=/src/ReducerComp2.jsx

ことの発端

階層を持つオブジェクトについてのフォームを作成していました。
以下は簡略化した例です。

.js
    const initdata = {
      dept1_a: "dept1_a",
      dept1_b: "dept1_b",
      dept1_c: {
        dept2_d: "dept2_d",
        dept2_e: "dept2_e"
      }
    };

このオブジェクトをuseReducerで管理し、各input要素のvalueにオブジェクトの値を、onChangeにdispatch関数をあてがっています。
(後々わかりますが、このreducer関数が原因です)

reducer1.js
  const reducer = (data, newDetails) => ({ ...data, ...newDetails });
  const [data1, dispatch1] = useReducer(reducer, initdata);

各項目ごとにinput要素を作り、そのonChangeでdispatch関数に値を渡し、オブジェクトを更新する場合は問題ありません。
しかし、例えば送信前のチェック処理等で全体に対し更新を行おうとしたとき、階層の深い方の項目は更新されませんでした。

例えば以下のようなイメージになります。

.js
    const initdata = {
      dept1_a: "UPDATED BY BOTTON",
      dept1_b: "UPDATED BY BOTTON",
      dept1_c: {
        dept2_d: "dept2_d", // Not Updated
        dept2_e: "UPDATED BY BOTTON""
      }
    };

何が原因か

原因はreducer関数と、それに合わせたdispatch関数の呼び方にあります。
以下はreducer関数と、dispatch関数を一度に使用している部分です。

.js
    // reducer関数
    const reducer = (data, newDetails) => ({ ...data, ...newDetails });

    // dispatch関数の使用
    const updateAllparam = () => {
      dispatch1({ dept1_a: "UPDATED BY BOTTON" });
      dispatch1({ dept1_b: "UPDATED BY BOTTON" });
      dispatch1({
        dept1_c: { ...data1.dept1_c, ...{ dept2_d: "UPDATED BY BOTTON" } }
      });
      dispatch1({
        dept1_c: { ...data1.dept1_c, ...{ dept2_e: "UPDATED BY BOTTON" } }
      });
    };

注目すべきは4回目の dept2_e を更新しようとする処理
dept1_c: { ...data1.dept1_c, ...{ dept2_e: "UPDATED BY BOTTON" } }です。
ここでは...data1.dept1_cで data1.dept1_c の値を取得しています。

ではこの data1.dept1_c の値は、どのような値でしょうか?(updateAllParamの中で変化しているのでしょうか?)
 
結論としては、この data1 の値は updateAllparam が呼ばれる前(おそらくこの捉え方も間違いで、多分正しくは最後にReactがレンダリングしたとき)の値であり、この時点では変化してないと言えます。

そのため、ここで取得した data1.dept1_c はもともとのままであり、dept2_d: "dept2_dで新たに上書きされてしまいます。

この事象は公式でも解説されています。
https://react.dev/reference/react/useReducer#ive-dispatched-an-action-but-logging-gives-me-the-old-state-value

私はこのとき、Reactがstateをどのように扱っているかやJSのイベントループ・タスクキューについてはまったく知りませんでした。
なので、このときの私は「Reactは裏でなんかキューっぽいことをして、最終的な値でレンダーするのかな? そうすると、setStateでprevを引数に入れないといけないのも合点がいくな。。。」というふうに思いまずはReactとキューについて検索しました。

Reactのレンダリングとキュー

調べた結果的には、やはりReactはそのレンダリングにあたってキューを使用しているようだった。

(というか、ドキュメントにはバリバリQueueと書いてあった)

But there is one other factor at play here. React waits until all code in the event handlers has run before processing your state updates. This is why the re-render only happens after all these setNumber() calls.
React は、イベント ハンドラー内のすべてのコードが実行されるまで待機してから、状態の更新を処理します。これが、これら全setNumber()の呼び出しの後でのみ再レンダリングが行われる理由です

個人的にはこちらの記事もわかりやすかった。

そして、「イベント ハンドラー内のすべてのコードが実行されるまで待機してから、状態の更新を処理します。」なのでやはり、レンダリングされる前に更新後の値を、変数から取得することはできないっぽい。。。

JavaScriptの非同期処理の仕組み

キューについて調べているうちに、そもそもJavaScriptはどのように非同期処理をしているのか疑問になりました。

呼ばれた関数はstackに置かれ評価される。
  →同期関数なら即時実行される
  →非同期関数なら、
    1. 非同期関数の引数として渡されたコールバック関数は Web APIs に送られる。
2. Web APIsで待機しているコールバック関数は、条件を満たすとtask queueに追加される

イベントループはstackを監視しており、stackが空ならばtask queueから取り出す。

こちらの動画が参考になります。

ただこの辺はなかなか奥が深そうな感じ。。。

どう解決すればいいか

本題に戻り、reducerをどうすれば良いか。
問題の根本は  const reducer = (data, newDetails) => ({ ...data, ...newDetails })としたために、深い階層にはreducerで提供されている変数を使用しなければならないが、Reactの仕組み上、それはできないから。

公式の例を見てみると、としてtypeを使っている。

.js
    function reducer(state, action) {
      switch (action.type) {
        case 'incremented_age': {
          return {
            name: state.name,
            age: state.age + 1
          };
        }
        case 'changed_name': {
          return {
            name: action.nextName,
            age: state.age
          };
        }
      }
      throw Error('Unknown action: ' + action.type);

Then you need to fill in the code that will calculate and return the next state. By convention, it is common to write it as a switch statement. For each case in the switch, calculate and return some next state.

とあるので、素直にswitchを使うようにしましょう!

.js
    // action = {paramsName: hoge, payload: fuga } 

    const reducer = (data, action) => {
        if (action.paramsName === "dept1_a") {
          return { ...data, dept1_a: action.payload };
        } else if (action.paramsName === "dept1_b") {
          return { ...data, dept1_b: action.payload };
        } else if (action.paramsName === "dept2_d") {
          return { ...data, dept1_c: { ...data.dept1_c, dept2_d: action.payload } };
        } else if (action.paramsName === "dept2_e") {
          return { ...data, dept1_c: { ...data.dept1_c, dept2_e: action.payload } };
        } else {
          return data;
        }
      };
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1