LoginSignup
1
2

useContextに意図しない更新が発生する

Last updated at Posted at 2023-10-14

はじめに

タイトルのような事象に出会いました。
解決に結構時間かかってしまったのですが、原因は javascript の基本知識が足りないためでした。
React で最小限の状況を再現しながら検証したいと思います。(CodeSandbox 使いました)

事象再現

最低限の検証用ソース

App.tsx
import { useState } from "react";
import Child from "./Child";
import { createContext } from "react";

export const AppContext = createContext({});

const App = () => {
  const [checkList, setCheckList] = useState<[]>([]);

  return (
    <AppContext.Provider value={{ checkList, setCheckList }}>
      <Child />
    </AppContext.Provider>
  );
};

export default App;
Child.tsx
import { useContext, useEffect, useState } from "react";
import { AppContext } from "./App";
import { Checkbox, Box } from "@mui/material";

// 初期リスト
const initList = [
  { id: 0, checked: true, name: "list_1" },
  { id: 1, checked: true, name: "list_2" }
] as any[];

const Child = () => {
  const appContext: any = useContext(AppContext);
  const { checkList, setCheckList } = appContext;

  const [list, setList] = useState<any[]>([]);

  // useState と useContext のリスト情報初期セットアップ
  useEffect(() => {
    setCheckList(initList);
    setList(initList);
  }, []);

  const checkConsole = (idx: number) => {
    console.log("useState管理のリスト  :", list[idx]);
  };

  const handleChange = (item: any) => {
    item.checked = !item.checked;
    const newList = [...list];
    setList(newList);

    checkConsole(item.id);
  };

  return (
    <Box sx={{ textAlign: "center" }}>
      <h1>Create CheckList!!!</h1>
      {list?.map((item: any) => {
        return (
          <Box
            key={item.id}
            sx={{
              fontSize: "36px",
              desplay: "inline-block",
              textAlign: "center"
            }}
          >
            <Checkbox
              size="large"
              checked={item.checked}
              onChange={() => handleChange(item)}
            />
            <label>{item.name}</label>
          </Box>
        );
      })}
    </Box>
  );
};

export default Child;

検証

画面はこんな感じで出来上がりました。
react_view_1.gif

以下内容のリストを保持し、onChange イベントでhandleChange()を発火してcheckedを更新することで、チェックボックスの on/off が切り替えられるようになっています。

const initList = [
  { id: 1, checked: true, name: "list_1" },
  { id: 2, checked: true, name: "list_2" }
] as any[];

実際、意図通りの挙動になっています。(クリックしたら checked が false に更新されチェックが外れる)
react_view_2.gif

ですが、handleChange()の中身がが明らかにおかしいです。

  const handleChange = (item: any) => {
    // 何してんのこれ?
    item.checked = !item.checked;

    // 何も起きなくない?
    const newList = [...list];
    setList(newList);

    // なぜ更新されている...?
    checkConsole();
  };

item
これはuseStateの値を引数として渡したものになりますが、state の直接更新をしているようです。
最終的にsetList()を実行しているのでViewに反映されていますが、
例えばsetList(newList)をコメントアウトした場合、以下のように state の値は更新されるのに View は変わらないという動きになってしまいます。
react_test_movie.gif

実装はおかしいですが、setList(newList)をするなら動作的には問題ないように見えます。
ですが、もう一つ問題がありました。

以下も一緒にコンソールに出してみます。

  const checkConsole = (idx: number) => {
    console.log("useState管理のリスト  :", list[idx]);
    console.log("useContext管理のリスト:", checkList[idx]);
    console.log("initList値          :", initList[idx]);
  };

・出力結果
react_view_3.gif

なぜかuseContextの値まで更新されている。。(ついでに初期値の配列も)

少し悩み、気づきました。

「あ、initList の参照渡しみたいなことが起こっているのか...」

ということでググります。

こちらの内容が一番正確?

javascript は参照渡し/値渡しという言葉は当てはまらないようです。
プリミティブ型の場合は値渡し、オブジェクトの場合は参照渡しという説明もありますが、正確には違うと私も思います。
記事では値の参照という言葉が使われています。
オブジェクトの場合はプリミティブ型と違い、この値の参照を更新しないケースがあるので、そこに誤解が生まれるのだと思います。

以下のように検証すると、本物の参照渡しとは違うことがわかります。

const x = { n: 1 };
const y = x;
let z = x;

y.n = 2;
z = { n: 3 };

console.log("x", x); // {n: 2} => yと共通のもの参照しているが、zとは別ものになっている
console.log("y", y); // {n: 2}
console.log("z", z); // {n: 3} => x,yの参照値に影響を与えていない

本物の参照渡しはz = { n: 3 }としたら x と y の参照値も更新されるはずです。

修正

ともあれ、これが原因っぽいので改めてソース見直すと

  const handleChange = (item: any) => {
    // この時点で initList/checkList は list と「値の参照」が同じのため更新されたことになっている
    item.checked = !item.checked;
    // newList は list の内容から配列を再生成しているため「値の参照」内容が変更となる
    const newList = [...list];
    setList(newList);

という予想になりましたでので、
値の参照が異なる newList に対して更新かければいけるってことでしょ」という考えで修正。

  const handleChange = (item: any) => {
    const newList = [...list];
    // 新規生成した配列要素に対して更新をかける
    newList[item.id].checked = !item.checked;
    setList(newList);

......治らない(._.)

原因はこれ。

console.log(`newList===list: ${newList === list}`);
// => false
console.log(`newList[item.id]===list[item.id]: ${newList[item.id] === list[item.id]}`);
// => true

-> 配列自体は同一参照の動きを回避できているが、配列要素が同一参照になっている

なるほど...
対象の配列は[{},{}]の形式なので{}も再生成してあげないと部分的に参照値の共有が発生しうる、ということですね...

ということで、これが正解のひとつかと思います。

  const handleChange = (item: any) => {
    // リスト内の要素も新規のオブジェクトで組み直す
    const newList = list.map((item) => ({ ...item }));

    newList[item.id].checked = !item.checked;
    setList(newList);
  };

onChange で useState の checked のみが更新されることも確認できました。
react_view_4.gif

コンソール出力を useState 更新と同関数内で実行していましたが、修正により setState 本来の正しい動きとなったため出力タイミングも修正しています。
(元のソースではinitListの直接更新となっていたので即反映されていた)

React(というか javascript)でネストの深いオブジェクトを扱う場合は今回の教訓を生かして十分注意したいと思います。

おわりに

原因特定には少々時間がかかってしまいました。
javascript の値渡し/参照渡しに関するルールがきちんとわかっている方だったら一瞬で解決する内容だったかと思います。
基礎大事!

参考

1
2
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
2