はじめに
ReactのチュートリアルでReactを学習していると、これまでRailsやDjangoでしかWeb開発をしたことがなかった自分にとっては理解するのに時間がかかる概念がいくつかあります。
その中でもstateはReactにおいて最重要かつ初対面すぎる概念の一つだと思ったので、アウトプット目的も兼ねて記事にまとめました。
本記事の対象となるのは、チュートリアルなどReactの学習を一通りやって、stateに関する理解を深めたい、確かめたい方です。
stateとは
簡単に言うと、stateはコンポーネントの記憶のようなもの。
コンポーネントはstateを使って、何を表示すべきか、また現在どのような状態にあるかを記録する。
例えば、フォームに入力されたテキストや、ユーザーによって切り替えられる表示モードなどがstateの典型的な使用例。
ReactがこれまでのWeb開発フレームワークに比べて、動的なUI設計に長けている要因の一つだと思う。
stateによるインタラクティビティ(双方向性)の追加
コンポーネントのメモリ
stateは以下のようにして定義できる。
import { useState } from 'react';
const [index, setIndex] = useState(0); // indexの値は0で初期化
index
が変数の名前で、setIndex
はindex
を更新するためのセッタ関数である。(変な定義の仕方やな。)
stateを更新する際は以下のようにする。
setIndex(1); // index = 1 になる
setIndex(index + 1); // indexに1を足した値になる
index = 1;
みたいにしないの?って感じだが、結論しない。
なぜstateの更新にはセッタ関数を使わないといけないのだろうか。
その理由は、stateはイミュータブル(書き換え不可能)であり、読み取り専用だからである。
読み取り専用だから、
const [index, setIndex] = useState(0);
index = 1; // 普通はこうしたいよね?
とかやっても意味がない(できない)。
意味がないというのは「このindexを後から変えても、定義した時点でもう画面にレンダーしちゃったよ!」ということらしい。
レストランで例えると、お客さんに料理を出した後に厨房で「やっぱこの料理には卵をのせよう!」とレシピを変えても、出してしまった料理に卵はのらない、ということかな。(天才)
じゃあセッタ関数ではなぜ更新できるのか。
セッタ関数によるstateの更新「setXxxxx()
」は、書き換えではなく 作り替え である。
そしてこの作り替えが行われたことをReactが察知すると再レンダーするため、あたかも書き換えられたかのように見えるというわけだ。
(卵をのせる新レシピで再度作った料理と素早く入れ替えることで、まるで元の料理皿に卵をのせただけに見せる。)
state はスナップショットである
const [number, setNumber] = useState(0);
などのようにして定義したstateを更新するコードがあったとしても、コードが読み込まれた時点で値をすぐに更新するのではなく、最後に一括で更新される。
そのため繰り返し同じstateに変更を加えるという処理を書いても、最後の変更だけが反映されて、それまでの処理は無かったことになる。
const [number, setNumber] = useState(0);
return (
...
// numberが現在0の場合
<button onClick={() => {
setNumber(number + 1); // 0+1=1だからnumberは1だね!
setNumber(number + 1); // 0+1=1だからnumberは1だね!
setNumber(number + 1); // 0+1=1だからnumberは1だね!
}}>ボタン</button>
...
上記のコードの場合、ボタンを押すとnumbeが3になるかと思いきや、number=1となる。
では、一つのイベントで複数回stateを更新したい場合はどうすればいいのだろう?
次を見てみよう。
一連の state の更新をキューに入れる
1 つのイベントで複数回 state を更新したい場合 setNumber(n => n + 1)
という形の更新用関数を使用できる。
const [number, setNumber] = useState(0);
...
return (
...
<button onClick={() => {
setNumber(n => n + 1); // 0 + 1 = 1
setNumber(n => n + 1); // 1 + 1 = 2
setNumber(n => n + 1); // 2 + 1 = 3
}}>ボタン</button>
...
この場合はnumber
は3になる。
ちなみに、n => n + 1
はnを引数としてn+1を返すという一つの関数であり、これをアロー関数という。
let number = 0
number = (n => n + 1)(number);
// とするとnumberは1になる
state 内のオブジェクトの更新
オブジェクト型のstateを更新する際も、以下のようにセッタ関数を使い、全ての要素を再度指定する必要がある。
// オブジェクト型のstate
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
// setPersonを使ってオブジェクトを更新(再定義)
setPerson({
firstName: '新しいfirstName',
lastName: person.lastName, // ここは今のやつ使う
email: person.email // ここも今のやつ使う
});
しかし要素が多いと長ったらしいので、変更したい部分だけ書く方法がある
setPerson({
...person, // 一旦今のpersonを完全コピー
firstName: e.target.value // 変更部分だけ書く
});
useState
ではなくuseImmer
を使うとさらに短く書ける。
useImmer
フックは、与えられた初期状態を基にしたドラフト(一時的なコピー)状態を作成する。
これだと変更したい要素だけ書けばいい。
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
updatePerson(draft => {
draft.name = '新しいname';
});
// (余談) ↑これ n=>n+1 のやつと同じで、関数に関数渡してる
補足:stateの定義での参照はポインタ
既存の値を参照して定義したネストオブジェクトstateの要素を扱う際は注意が必要である。
例えば以下のようにしてstateを定義した場合を考える。
const initialPosition = {
x: 0,
y: 0
};
export default function Canvas() {
const [shape, setShape] = useState({
color: 'orange',
position: initialPosition // initialPositionを参照
});
...
ここで重要なのは、shapeというstateオブジェクトは、あくまで「color
とposition
という要素を持つのよ〜」という、トップレベルの情報しか持っていない。
そのためposition
の中身はinitialPosition
を参照することでネストオブジェクトを演じているといえる。
これで、もしshapeのpositionを変更したいなぁと思ったとき、
shape.position.x += 1;
のようにしてしまうと、元のinitialPosition
が書き換えられる。
(stateではないinitialPosition
に対する変更とみなされるので正しいコードとして実行できる。)
もし元のinitialPosition
を書き換えたくない場合は、やはり以下のようにsetShapeを使って新たにオブジェクトを生成する必要がある。
setShape({
...shape,
position: {
x: shape.position.x + 1,
y: shape.position.y + 1,
}
});
とにかく、
セッタ関数を使ってstateを更新したら完全に新しいオブジェクトが作り直されるんだ
と覚えておこう。
state 内の配列の更新
配列の更新がjavascriptとは違う
使わない(配列を書き換える) | 使う(新しい配列を返す) | |
---|---|---|
追加 | push, unshift | concat, [...arr] spread syntax (https://ja.react.dev/learn/updating-arrays-in-state#adding-to-an-array) |
削除 | pop, shift, splice | filter, slice (https://ja.react.dev/learn/updating-arrays-in-state#removing-from-an-array) |
要素置換 | splice, arr[i] = ... 代入文 | map (https://ja.react.dev/learn/updating-arrays-in-state#replacing-items-in-an-array) |
ソート | reverse, sort | 先に配列をコピー (https://ja.react.dev/learn/updating-arrays-in-state#making-other-changes-to-an-array) |
配列もオブジェクト扱いなのでセッタ関数を使う
const [name, setName] = useState('');
const [artists, setArtists] = useState([]);
// 配列の末尾に要素追加する
setArtists([
...artists,
{ id: nextId++, name: name }
]
);
// 配列の先頭に要素追加する
setArtists([
{ id: nextId++, name: name },
...artists
]);
// 要素の削除
// idが1でない artists のみの配列を作成する
// => つまりidが1のartist要素を削除する
setArtists(
artists.filter(a => a.id !== 1)
);
// 配列の置換1(属性で指定)
setArtists(
artists.map(artist => { // for artist in artists みたいだなぁ
if (artist.name === '古いname') {
return {
...artist,
name: '新しいname',
};
} else {
return artist;
};
}
});
)
// 配列の置換1(インデックスで指定)
setArtists(
artists.map((artist, i) => { // 第一引数は要素、第二引数はインデックス
if (i === 1) {
return {
...artist,
name: '新しいname',
};
} else {
return artist;
};
}
})
);
// 配列への挿入
setArtists([
...artists.slice(0, 3),
{ id: nextId++, name: name }, // 3番目に挿入
...artists.slice(3),
]);
// ソート
const copiedArtists = [...artists]; // 一度コピーをする ※
copiedArtists.reverse(); // コピーに対して変更を加える
setArtists(copiedArtists); // そのコピーを当てはめる
※ これは浅いコピー
ネストオブジェクトを更新する際には、更新したい箇所からトップレベルまでのコピーを作成する必要がある。
以下はオブジェクトの変更として好ましくない例
const copiedArtists = [...artists]; // 浅いコピー
copiedArtists[0].name = '新しいname'; // コピー元のartistsが変更される
setArtists(copiedArtists);
補足:アロー関数内のブレース
上記の「要素の削除」や「配列の置換」の例では、アロー関数を使った。
アロー関数はこのようにして使われる。
// artistsをidが1じゃないやつだけのものに置き換える = idが1の要素を削除
setArtists(
artists.filter(a => a.id !== 1) // "a => a.id !== 1" がアロー関数
);
一方で、ブレース{}
を使った方法もあった。
artists.map(a => { // "a => {" から以下アロー関数
...
return a;
});
ブレース{}
は関数の本体を定義し、その中には複数の文を記述できる。
しかしreturn ステートメントを明示的に記述しなければ、関数から何も返されない。
filterメソッドの例では、a.id !== 1
は評価されるが、その結果はどこにも返されず、関数は undefined を返す。
そのため、filterメソッドは常に false
(もしくは false と同等の値)を返すと判断し、結果として何もフィルタリングされなくなる。
ブレース{}
を省略すると、その式の評価結果が自動的に返されるため、return
も必要ない。
まとめ
ここまでやってみて、実際はこれら全て理解していなくてもWebサイトは実装はできそうですが、それだと多分思った通りに動かなくて結局苦戦する部分もあると思うので一旦最後まで理解しておきます。
まだあるのですが長くなったので次回の記事で。