Reactのステートは注意が必要です!
Reactを利用してフロントエンドのUI(ユーザーインターフェース)を作ると、変更があった部分だけを書き換えてくれるので便利です。これに関連して、前回投稿した変数の扱いでReactのステートを使う場合には注意する必要があるという話を書きました。
これに関しては、ご質問が多かったのでもう少し詳しく書いて行く事にします。
この機能を利用するには「ステート(state)」で変数を管理して、ステートの変化のイベントを検出する必要があります。ところが、配列やオブジェクトはReactのステートではイミュータブルではないので、正しく実装しないと意図した動作をしない事があります。
入力した文字列を配列に追加するアプリ
少し具体的な例で説明をします。 シンプルなあぷりとして、文字列を入力する「input」とボタンのシンプルなフォームでボタンが押されたら、入力された文字列を配列に追加するアプリを考えます。
これを、Reactのクラスコンポーネントで書くと
import React from "react";
import { produce } from "immer";
import "bootswatch/dist/spacelab/bootstrap.min.css";
import "./App.css";
interface IProps {}
interface IState {
samples: Array<string>;
}
class App extends React.Component<IProps, IState> {
private field = React.createRef<HTMLInputElement>();
constructor(props: IProps) {
super(props);
this.state = {
samples: [],
};
}
add_bad() {
if (this.field.current) {
const value = this.field.current.value;
// ステートの変数を直接操作するのは基本的に規定外
// 「setState」を介して更新するのが基本です
this.state.samples.push(value);
this.field.current.value = "";
console.log(this.state.samples);
}
}
add() {
if (this.field.current) {
const value = this.field.current.value;
const update = produce(this.state.samples, (draft) => {
draft.push(value);
return draft;
});
this.setState({
samples: update,
});
this.field.current.value = "";
}
}
render() {
return (
<div className="App">
<div className="form-group">
<label>New Item</label>
<input className="form-control" type="text" ref={this.field} />
<button className="btn btn-primary" onClick={() => this.add()}>
Add
</button>
<button className="btn btn-danger" onClick={() => this.add_bad()}>
Add
</button>
</div>
<h1>Test</h1>
<table className="table table-hover">
<thead>
<tr className="table-primary">
<th>Item</th>
</tr>
</thead>
<tbody>
{this.state.samples.map((item) => (
<tr key={item}>
<td>{item}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
}
export default App;
こんな感じになります。
この例ではボタンを2つ用意していて、一つは「add()」もう一つは「add_bad()」というメソッドを呼び出しています。
違いは、「add_bad()」の処理は、
this.state.samples.push(value);
一方で、「add()」の処理は、
const update = produce(this.state.samples, (draft) => {
draft.push(value);
return draft;
});
this.setState({
samples: update,
});
とちょっと見慣れない処理をしています。これは、「immer」というnpmのモジュールをですが、簡単に言うと、「this.state.sampls」の複製(つまり別の配列・入れ物)を作ってそちらを更新したあとに、「this.state.samples」を置き換えています。
実際にアプリを動かしてみると、「add_bad()」の場合画面が更新されませんが、「add()」を使った方は、入力した文字列が追加されて表示も更新されます。
コードに、「console.log(this.state.samples)」を入れてあるので、これを見ると中身は更新されているのに表示が変わらないという問題が起きていることがわかります。
これは、Reactが配列の「中身の変更」を検知できないからです。
明示的に、配列を置き換えないと思ったような動作になりません。しかし、これはエラーにもならないので、知らないと中々問題がわかりにくいバグです。
React Hookでは少し違った挙動です
Reactの場合、クラスコンポーネント以外にも関数コンポーネントで実装する方法もあります。本質的には同じ問題があって、クラスを使った場合と同じように「コピー」を作って、配列そのものを明示的に入れ替えないと動作しません。
しかし、関数コンポーネントを使った場合、直接ステートで指定した変数の中身に変更を加えようとするとエラーになります。
React Hooksを使った例です。
import React, { FunctionComponent, useState } from "react";
import { produce } from "immer";
import "bootswatch/dist/spacelab/bootstrap.min.css";
import "./App.css";
const field = React.createRef<HTMLInputElement>();
const Home: FunctionComponent<{ initial?: Array<string> }> = ({
initial = [],
}) => {
const [samples, setSamples] = useState<Array<string>>(initial);
function add() {
if (field.current) {
const value: string = field.current.value;
const update: Array<string> = produce(
samples,
(draft: Array<string>): Array<string> => {
draft.push(value);
return draft;
}
);
setSamples(update);
field.current.value = "";
}
}
function add_bad() {
if (field.current) {
const value = field.current.value;
samples.push(value);
setSamples(samples);
field.current.value = "";
console.log(samples);
}
}
return (
<div className="App">
<div className="form-group">
<label>New Item</label>
<input className="form-control" type="text" ref={field} />
<button className="btn btn-primary" onClick={() => add()}>
Add
</button>
<button className="btn btn-danger" onClick={() => add_bad()}>
Add
</button>
</div>
<h1>Test</h1>
<table className="table table-hover">
<thead>
<tr className="table-primary">
<th>Item</th>
</tr>
</thead>
<tbody>
{samples.map((item) => (
<tr key={item}>
<td>{item}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default Home;
基本的に同じコードですが、「add_bad()」の方は実行時にエラーになります。
Home.tsx:31 Uncaught TypeError: Cannot add property 1, object is not extensible
このオブジェクトは変更できないという例外が発生するので、動作もしませんが原因が直ぐにわかるようになっています。
いづれにしても、「ステート」で定義している変数を「所定の関数(メソッド)」を通さずに変更するのは、間違ったコーディングなので動作しないのは問題ではないのですが、問題がわかりにくいところで注意が必要ということです。
配列やオブジェクトのコピーの作り方
この例では、npmの「immer」というモジュール(パッケージ)を利用して、ステートの変数のコピーを作っていますが、要は別の変数(別の入れ物)の複製があれば良いので実装の方法は幾つかあります。別のパッケージの「immutable」なども利用できますし、
Javascriptの「...」なども使えます。
個人的には、「immer」が使いやすいと思うので私自身はこの例のように「immer」で対応しています。
Reactのクラスコンポーネントを利用する場合、コンポーネントがマウントされていない場合は、「setState」では、値を更新できないために、エラーにしていないと推定されます。したがって、クラスコンポーネントで実装する場合は、エラーにならないので実装時には注意する必要があります。
反面、React Hooksを使うとステートの変数の直接の操作はエラーになります。ただし、実行時のエラー(Runtimeエラー)なので、テストが抜けていると見落とす可能性があります。
いずれにしても、Reactの場合、配列やオブジェクトの扱いがイミュータブルの扱いなので更新の際は「コピーを作って置き換える」必要があることを忘れないようにすることが大切です。
Vueの場合は?
Vueの場合は基本的にミュータブルです。つまり、作成後の更新が認められていて、配列やオブジェクトでも変更を加えることが可能です。
このあたりは、Reactよりは扱いやすいと言えます。
この違い自体はプログラム上は余り大きな問題ではなく、知っているかいないかという意味合いが多いきいものです。しかし、知らない場合、問題が起きても原因がわからないですし、原因を見つけるまで時間がかかる問題です。
同じような問題に直面する人も多いようで検索するとこの記事のような情報がたくさん見つかります。
こうした、イミュータブル、ミュータブルの問題が起きにくいVueの方が初心者が扱いやすい一つの理由だとおもいます。
Vueの場合は、VUEXも含めてミュータブルです。Reactの場合はReduxを使っても同様の問題が起きる可能性があるので注意が必要です。
まとめ
Reactのステート変数で、配列やオブジェクトを扱う場合、データを更新する際は「コピー(複製)」を作って置き換える必要があります。
この操作をしないと、エラーになったり、意図するような表示の更新が行われません。
複製の作成には、公開されているモジュールの「immer」 や「immutable」などが便利ですが、Javascriptの機能だけでも作成できます。サンプルがインターネットにたくさん公開されているので参考にされるとよいかと思います。
実際のプログラミングの場面ではいろいろなやり方を知っている必要はなく、1つで良いので対処方法を知っていれば問題はありません。外部モジュールを使った方が、コードもシンプルになる場合が多いので、「immer」などの利用をお勧めします。
ReactクラスコンポーネントとReact Hooksでは挙動も若干異なるのでこのあたりも知っていると実際の実装で役立ちます。