📝

React初心者がReactを学んでみた

に公開

こんにちは、くわっちです!
Reactを触れるにあたり、そもそもReactって?となったためにReactを学習しました!
基本的に公式サイトを見て、自分なりにまとめたものとなっています。
これを読んで、Reactを理解するのに役立てたら良いなと思います。

React

Reactとは、Meta社によって開発された、オープンソースなフロントエンド向けのJavaScriptライブラリです。コンポーネントからユーザインターフェースを作成するものなので、1つのページを小さな部品に分けて、管理しやすくなるし、コンポーネントの再利用もできる便利なライブラリです。
さらに、状態が変わるとReactが勝手に画面を更新してくれるのです!!リロードしなくてもUIが変化する!便利ですねぇ〜

いやいや、勝手に更新するといってもいつ更新されるのだよって話ですよね。更新タイミングを知らないと、必要な時に更新できず、不必要な時にバリバリ更新するという現象が起きてしまいます。
では、Reactはどのタイミングで画面を更新してくれるのでしょうか?

画面の更新タイミング

画面の更新タイミングは、ずばり!!stateが変わった時!!
なるほど🧐完全に理解した!とはなりませんよね笑
stateって何だよ!教えろよ!という罵倒が聞こえた気がしました。

stateとは

stateをひとことで言うと「状態」です。

では、どのようにstateを使うのでしょうか?
ボタンを押したら数字が1増えるカウンターを作ってみましょう。

まず、useStateをインポートします。

import { useState } from 'react';

次に、stateを作成します。

const [number, setNumber] = useState(0);

これは、numberが今の状態を表す変数で、setNumbernumberを変更するための関数です。useState(0)はReactに「state(number)を作って!最初の値は0だよ」とお願いしてる感じです。
最後に、このstateをコンポーネントに追加し、カウンターを作りましょう。

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
      }}>+1</button>
    </>
  )
}

最初はnumber0が入っているため画面上には0が表示されます。
ボタンを1回押すことで、画面上には1が表示されます。
当たり前かと思いますが、なぜ画面が更新されたのでしょう?
Reactには仮想DOMというものがあり、変更前の画面の状態と変更後の画面の状態の差分をチェックして変更された部分だけ更新します。下記の図は、この仕組みをイメージして自作したものです。

つまり、ボタンを押すことでnumberのstateは0から1に変わり、Reactは「stateが変更された!!画面を更新するぞ!」となり画面上には1が表示されたということです。
画面全体を更新するのではなく、変更部分だけ更新するので、描画が素早い!!

state 更新をまとめて処理する

ここでは、先ほどのカウンターをベースに一気に3増やすカウンターを作ってみましょう。

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

+3したいだけだからsetNumber(number + 1)を3回呼び出せばいいじゃん!
しかし、ボタンをクリックしても、実際には+1しかされません。
なぜでしょうか?
それは、

  • 最初のレンダー内でのnumberは常に0である
  • イベントハンドラ内のすべてのコードが実行されるまで、Reactはstateの更新処理を待機する

つまり

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

が全て実行されてからレンダリングされるので、実際には+1となってしまうのです。
では、どうすれば一気に+3できるのでしょうか?

次のレンダリング前に同じ state を複数回更新する

setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
キュー内の更新処理 n 返り値
n => n + 1 0 0 + 1 = 1
n => n + 1 1 1 + 1 = 2
n => n + 1 2 2 + 1 = 3
  1. setNumber(n => n + 1): n => n + 1 は関数。React はこれをキューに追加する。
  2. setNumber(n => n + 1): n => n + 1 は関数。React はこれをキューに追加する。
  3. setNumber(n => n + 1): n => n + 1 は関数。React はこれをキューに追加する。

こうすることで、上記の例を理由に正しく+3されるのです。

state内の配列の更新

Reactのstate内にある配列は、読み取り専用として扱う必要があります。
下記の表は、配列の操作ごとにReactで「使わない(配列を書き換える)」と「使う(新しい配列を返す)」をまとめたものです。

使わない(配列を書き換える) 使う(新しい配列を返す)
追加 push, unshift concat, [...arr] spread syntax
削除 pop, shift, splice filter, slice
要素置換 splice, arr[i] = ... 代入文 map
ソート reverse, sort 先に配列をコピー

ここでは、登録した名前を一覧にする例で考えてみましょう。

全コード
import { useState } from 'react';

let nextId = 0;

export default function List() {
  const [name, setName] = useState('');
  const [artists, setArtists] = useState([]);

  function handleAddArtists() {
    setArtists(
      [
        ...artists,
        { id: nextId++, name: name }
      ]
    );
  }

  function handleDeleteArtists(id) {
    setArtists(artists.filter(artist => artist.id !== id));
  }

  function handleChangeArtists(id) {
    setArtists(artists.map(artist => {
      if (artist.id === id) {
        return {
          ...artist,
          name: artist.name + ' (変更)'
        };
      } else {
        return artist;
      }
    }));
  }
  
  function handleReverseArtists() {
    const nextList = [...artists];
    nextList.reverse();
    setArtists(nextList);
  }

  return (
    <>
      <h1>Inspiring sculptors:</h1>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
      />
      <button onClick={handleAddArtists}>Add</button>
      <button onClick={handleReverseArtists}>Reverse</button>
      <ul>
        {artists.map(artist => (
          <li key={artist.id}>
            {artist.name}
            <button onClick={() => handleChangeArtists(artist.id)} >Change</button>
            <button onClick={() => handleDeleteArtists(artist.id)} >Delete</button>
          </li>
        ))}
      </ul>
    </>
  );
}

追加

function handleAddArtists() {
    setArtists(
      [
        ...artists,
        { id: nextId++, name: name }
      ]
    );
}

既存の要素の末尾に、新しい要素が加わった新しい配列を作成します。

削除

function handleDeleteArtists(id) {
    setArtists(artists.filter(artist => artist.id !== id));
}

フィルタリングして取り除いた、新しい配列を作ります。つまり、その要素を含まない新しい配列を生成するということです。

要素置換

function handleChangeArtists(id) {
    setArtists(artists.map(artist => {
      if (artist.id === id) {
        return {
          ...artist,
          name: artist.name + ' (変更)'
        };
      } else {
        return artist;
      }
    }));
}

要素を置き換えるには、map()を使って新しい配列を作成しましょう。

ソート

function handleReverseArtists() {
    const nextList = [...artists];
    nextList.reverse();
    setArtists(nextList);
}

JavaScriptのreverse()やsort()メソッドは元の配列を書き換えるため、直接使うことはできません。
ただし、最初に配列をコピーしてから、そのコピーに変更を加えることはできます。

state内の配列の更新のまとめ

  • 配列をstateに入れることができるが、それを直接変更してはいけない
  • 配列を変更せず、代わりに新しい版を作成し、stateを更新する
  • [...arr, newItem]という配列スプレッド構文を使用して、新しい項目を持つ配列を作成できる
  • filter()やmap()を使用して、フィルタリングされた、あるいは変換されたアイテムを含む新しい配列を作成できる

keyによるリストアイテムの順序の保持

配列の各アイテムには、keyを渡す必要があります。これは、配列内の他のアイテムと区別できるようにするための一意な文字列ないし数値のことです。

keyは、配列のどの要素がどのコンポーネントに対応するのかをReactが判断し、後で正しく更新するために必要なものです。適切にkeyを選ぶことで、Reactは何が起こったか推測し、DOMツリーに正しい更新を反映させることができます。
これにより、兄弟間で項目を一意に識別できます。

state構造の原則

関連するstateをグループ化する

2つ以上のstate変数を常に同時に更新する場合、それらを単一のstate変数にまとめることを検討してください。まとめることで、state変数の一部を変更し忘れることを防ぐことができます。

const [position, setPosition] = useState({ x: 0, y: 0 });

stateの矛盾を避ける

state の複数部分が矛盾して互いに「衝突する」構造になっている場合、ミスが発生する余地があります。

const [isSending, setIsSending] = useState(false);
const [isSent, setIsSent] = useState(false);

何かメッセージを送る時に、送ってる途中のisSendingと送り終えたisSentは同時にtrueにはなりませんよね?しかし、この書き方だとバグで起きるかもしれません。

const [status, setStatus] = useState('typing');

上記のようにstateを1つにし、typing → sending → sent という明確な流れを作り、矛盾を避けましょう。

冗長なstateを避ける

コンポーネントのpropsや既存のstate変数からレンダー時に何らかの情報を計算できる場合、その情報をコンポーネントのstateに入れるべきではありません。

state内の重複を避ける

同じデータが複数のstate変数間、またはネストしたオブジェクト間で重複している場合、それらを同期させることは困難です。

変更前
import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2> 
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedItem(item);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

変更前のコードだと、いずれかの項目の “Choose” をクリックしてから編集すると、入力欄は更新されますが、下部のラベルは編集内容を反映していません。これはstateに重複があり、selectedItem側の更新を忘れたためです。

変更後
import { useState } from 'react';

const initialItems = [
  { title: 'pretzels', id: 0 },
  { title: 'crispy seaweed', id: 1 },
  { title: 'granola bar', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find(item =>
    item.id === selectedId
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>What's your travel snack?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedId(item.id);
            }}>Choose</button>
          </li>
        ))}
      </ul>
      <p>You picked {selectedItem.title}.</p>
    </>
  );
}

以前はstateがこのように重複していました。

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedItem = {id: 0, title: 'pretzels'}

変更後は

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedId = 0

このようにすることで、重複がなくなり必要なstateだけが残っているようになりました。
さらにレンダリング後にitems.find(...) がタイトル更新後の項目を見つけてくるので、メッセージも更新されるようになります。

stateの保持とリセット

stateはレンダーツリー内の位置に結びついている

コンポーネントにstateを与えると、そのstateはそのコンポーネントの内部で「生存」しているように見えるが、実際にはstateはReactの中に保持されています。
2つのカウンターを例に考えてみましょう。

import { useState } from 'react';

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      <Counter />
      {showB && <Counter />} 
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={e => {
            setShowB(e.target.checked)
          }}
        />
        Render the second counter
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

これらはツリー内の別々の位置にレンダーされているため、2つの別々のカウンタとして動作しています。
カウンタのうち1つが更新されると、そのコンポーネントのstateだけ更新されます。

2つ目のカウンタのレンダーをやめた瞬間、そのstateは完全に消えます。
これは、Reactがコンポーネントを削除する際にそのstateも破棄するからです。

2つ目のカウンタを再レンダーさせるとstateが初期化され(score = 0)、DOMに追加されます。

Reactは、UIツリーの中でコンポーネントが当該位置にレンダーされ続けている間は、そのコンポーネントのstateを維持します。

同じ位置の同じコンポーネントはstateが保持される

ここからは、チェックボックスの選択状態を切り替えることで、同じ位置にあるカウンターを切り替えられる例で考えていこうと思います。

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <Counter isFancy={true} /> 
      ) : (
        <Counter isFancy={false} /> 
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

チェックボックスの選択状態を切り替えても、カウンタのstateはリセットされません。
なぜでしょうか?
これは、ルートのAppコンポーネントが返すdivの最初の子は常に<Counter />だからです。

ここまでで、stateは位置に依存していることがわかりました。
しかし時には、同じ位置のコンポーネントのstateをリセットしたい場合があるでしょう。
では、どうすればいいのでしょうか?

同じ位置の異なるコンポーネントはstateをリセットする

先ほどと同じような例で考えてみましょう。

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <div>
          <Counter isFancy={true} /> 
        </div>
      ) : (
        <section>
          <Counter isFancy={false} />
        </section>
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Use fancy styling
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Add one
      </button>
    </div>
  );
}

今回は、チェックボックスの選択状態を切り替えるとカウンタのstateがリセットされます。
同じ位置なのに驚きですね。
これは、Counterをレンダーしているとこは同じでも<div>の最初の子がsectionからdivに変わっているからです。

keyでstateをリセットする

ほかにも、keyを使う方法があります。

{isPlayerA ? (
  <Counter key="Taylor" person="Taylor" />
) : (
  <Counter key="Sarah" person="Sarah" />
)}

keyを指定することで、親要素内の順序ではなく、key自体を位置に関する情報としてReactに使用させることができます。

削除されたコンポーネントのstateの保持

ここまでで、stateの保持やリセットの仕組みについて理解できたかと思います。しかし、実際の開発では「一度非表示になったコンポーネントのstateを保持したままにしたい」というケースもあるでしょう。そこで、そのような場合に役立つ方法を以下で紹介します。

  • stateをリフトアップする
    stateをリフトアップすることで、stateを親コンポーネントで保持することができます。
  • 別の情報源を利用する
    Reactのstateに加えて、localStorageのような別の情報源から読み込んでstateを初期化し、下書きを保存するようにできます。

refで値を参照する

コンポーネントに情報を「記憶」させたいが、その情報が新しいレンダーをトリガしないようにしたい場合、refを使います。

import { useRef } from 'react';
const ref = useRef(0);

useRefは以下のようなオブジェクトを返します。

{ 
  current: 0
}

refとstateの違い

ref state
useRef(initialValue) は { current: initialValue } を返す useState(initialValue) はstate変数の現在の値とstateセッタ関数を返す([value, setValue])
変更しても再レンダーがトリガされない 変更すると再レンダーがトリガされる
ミュータブル - レンダープロセス外でcurrentの値を変更・更新できる ”イミュータブル” - state変数を変更するためには、再レンダーをキューに入れるためにstateセッタ関数を使用する
レンダー中にcurrentの値を読み取る(または書き込む)べきではない いつでもstateを読み取ることができる。ただし、各レンダーには独自のstateのスナップショット があり変更されない

refまとめ

  • refは一般的な概念だが、ほとんどの場合、DOM要素を保持するために使用する
  • <div ref={myRef}>のように渡すことで、ReactにDOMノードをmyRef.currentに入れるよう指示する
  • 通常、フォーカス、スクロール、またはDOM要素の測定などの非破壊的なアクションにrefを使用する
  • Reactによって管理されるDOMノードの変更を避ける
  • Reactによって管理されるDOMノードをどうしても変更する場合は、Reactが更新する理由のない部分のみ変更する

イベント伝播

export default function Toolbar() {
  return (
    <div className="Toolbar" onClick={() => {
      alert('You clicked on the toolbar!');
    }}>
      <button onClick={() => alert('Playing!')}>
        Play Movie
      </button>
      <button onClick={() => alert('Uploading!')}>
        Upload Image
      </button>
    </div>
  );
}

この<div>には2つのボタンが含まれていて、それぞれonClickハンドラを持っている場合、ボタンをクリックしたとき、どのハンドラが実行されるでしょうか?
どちらのボタンをクリックしても、最初にそれ自体のonClickが実行され、その後で親である<div>onClickが実行されます。
つまり、イベントは上方向に伝播するのです。

しかし、親要素に伝播させたくない場面は多々あると思います。その場合どのようにすれば良いのでしょうか?

伝播の停止

伝播を停止したい場合は、イベントが親要素(ツリーの上位)に伝わるのを防ぐe.stopPropagation()を使用します。先ほどの<button>に追加しましょう。

<button onClick={(e) => {e.stopPropagation();alert('Playing!');}}>
    Play Movie
</button>

これで、ボタンを押しても子要素で発生したイベントが親要素に伝播せず、親のイベントハンドラは実行されなくなりました。

エフェクトを使って同期を行う

エフェクトとは、特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのものです。
エフェクトは、コミットの最後に、画面が更新された後に実行されます。

エフェクトの書き方

1.エフェクトを宣言する

import { useEffect } from 'react';
function MyComponent() {
  useEffect(() => {
    
  });
  return <div />;
}

コンポーネントがレンダリングされるたびに、Reactは画面を更新し、その後でuseEffect内のコードを実行する流れになります。

2.エフェクトの依存配列を指定する
エフェクトは全てのレンダリング後に実行されるが、これが望ましくない場合があります。

  • 時にはそれが遅いことがある
    外部システムとの同期は常に瞬時に起こるものではないため、必要でない限り行わない方が良いです。
  • 時にはそれが間違っていることがある
    例えば、キーストロークごとにコンポーネントのフィードインアニメーションを開始したくない場合は、コンポーネントが初めて表示される時に1回だけ再生されるべきです。
  useEffect(() => {
    // ...
  }, []);

useEffectの呼び出しの第2引数として依存値の配列を指定することで、Reactにエフェクトの不必要な再実行をスキップするように指示できます。第2引数が空の場合は、マウント時に一度だけ実行されます。

おわりに

ここまで、長々と読んでいただきありがとうございました。Reactを完全に理解しましたよね?私は、useState,useRef,useEffectについては完全に理解しました!これを読んで、何かしらの新しい知見があったり、Reactに立ち向かう覚悟ができたりしたらくわっち大満足です!!
Reactの状態管理は難しいがパズルみたいで楽しいかも?
以上、「React初心者がReactを学んでみた」でした!!
またの機会でお会いしましょう〜

jig.jp Engineers' Blog

Discussion