【6日目】React初学者が1週間でどこまで出来るか挑戦(イベントとState)

はじめに

昨日、一昨日と予定があり日が空いてしまいましたが、React学習1週間チャレンジ6日目です。

前回まででInertiaのへルパなどを使ってCreateフォームができました。しかしInertiaがいい感じに処理してくれるため、ReactのuseStateなどよく分からないままでした。そこで今回は改めてReactドキュメントに沿ってイベントハンドラやState管理を学びました。

Adding Interactivity の各チャプタを一気に読み切りました。要所は自分のコードに置き換えて理解を深めています。

Responding to Events

ドキュメント: Responding to Events – React

イベントハンドラ関数

イベントハンドラ関数は

  • 通常はコンポーネント内で定義される
  • handleから始まるイベント名で命名する(例: handleClick
    • 他の命名規則でも動作はする(例: testClick
  • インラインで関数も書くことができる
  • アロー関数を使って書くこともできる
export default function Button() {
  function handleClick() {
    alert('handle + イベント名 が通常の命名規則');
  }

  function testClick() {
    alert('他の命名規則でもOK');
  }

  return (
    <>
      <button onClick={handleClick}>handleClick</button>
      <button onClick={testClick}>testClick</button>
  
      <button onClick={function inlineClick () {
        alert('インラインは短い関数を書くのに便利!');
      }}>inlineClick</button>
  
      <button onClick={() => {
        alert('アロー関数も使えます');
      }}>arrowClick</button>
    </>
    );
}

イベントハンドラでpropsを使う

function ActionButton({ message, children }) {
  return (
    <button onClick={() => alert(message)}>
      {children}
    </button>
  );
}

export default function OperationBar() {
  return (
    <>
      <ActionButton message="更新します">
        更新
      </ActionButton>
      <ActionButton message="削除します">
        削除
      </ActionButton>
    </>
  );
}

イベントハンドラをpropsとして渡す

例えば、ある共通の部品を以下のような条件で作りたいとする。

  • 同じ見た目のボタンであること
  • クリックするとクリックイベントを起こすこと
  • ただしクリックイベントの挙動は個別に決めたい

次のように親コンポーネント(Button)にonClickのようにイベントハンドラを渡す。

function Button({ onClick, children }) {
  return (
    <button onClick={onClick}>
      {children}
    </button>
  );
}

function StoreButton({ targetName }) {
  function handleStoreClick() {
    alert(`${targetName}を登録します。`)
  }
  return (
    <Button onClick={handleStoreClick}>
      {targetName} 登録
    </Button>
  );
}

function UpdateButton() {
  return (
    <Button onClick={() => alert('更新します')}>
      更新
    </Button>
  );
}

function DeleteButton() {
  function handleDeleteButton() {
    if (window.confirm('本当に削除しますか?')) {
      // 削除処理省略
      console.log('Item has been deleted.');
    }
  }
  
  return (
    <Button onClick={handleDeleteButton}>削除</Button>
  ); 
}

export default function OperationBar() {
  return (
    <>
      <StoreButton targetName="ユーザー" />
      <StoreButton targetName="顧客" />
      <UpdateButton />
      <DeleteButton />
    </>
  );
}

イベントハンドラpropsの命名

  • <button><div>などビルトインコンポーネントはonClickなどのブラウザイベント名のみ対応している
  • コンポーネントを作る時に、独自のイベントハンドラpropsを命名することもできる
    • onから始めるのが慣例(例: onSmash

上記のサンプルコードをonClickから独自のonSmashに書き換えると以下のようになる。

function Button({ onSmash, children }) {
  return (
    <button onClick={onSmash}>
      {children}
    </button>
  );
}

function StoreButton({ targetName }) {
  function handleStoreClick() {
    alert(`${targetName}を登録します。`)
  }
  return (
    <Button onSmash={handleStoreClick}>
      {targetName} 登録
    </Button>
  );
}

// ... 省略

ブラウザ対応の<button>タグではonClickが必要だが、自分で定義したButtonコンポーネントにはonSmashが使える。

その他

イベントの伝播、伝播の止め方、ブラウザのデフォルト動作の止め方についてJavaScriptの知識でカバーできそうなため詳細は省略。

State: A Component's Memory

ドキュメント: State: A Component's Memory – React

useStateの理解

import { useState } from 'react';

const [state, setState] = useState(initialState);
  • レンダリング間で情報を保持するためには状態変数(state variable)を使う
  • 状態変数はuseStateフック(Hook)を使って宣言する
  • フックはuseから始まる特別な関数
  • [something, setSomething]の構文は配列の分割代入

useState使用例

ローカルの変数はレンダリング間で保持できない。例えば、以下のようにボタンを押すたびに変数indexhandleClick関数で1増やす処理を実行する。handleClick関数内のconsole.logの結果はインクリメントされているが、レンダリングされているIndex: {index}の部分は変化がない。

export default function () {
let index = 0;

function handleClick() {
  index = index + 1;
  console.log(`index結果: ${index}`); // 1, 2, 3 ...と増える
}

  return (
    <>
      Index: {index} {/* 変化無し */}
      <button onClick={handleClick}>
        増やす
      </button>
    </>
  );
}

次のようにuseStateを使って管理すること解決できる。

import { useState } from "react";

export default function () {
  const [index, setIndex] = useState(0);

  function handleClick() {
    setIndex(index + 1);
    console.log(`index結果: ${index}`); // 0, 1, 2 ...と増える
  }

    return (
      <>
        Index: {index}. {/* 1, 2, 3 ...と増える */}
        <button onClick={handleClick}>
          増やす
        </button>
      </>
    );
  }

setIndex(index + 1);の後にconsole.logindexを確認すると、初回1ではなく0になるのは後のチャプターで出てくるが、状態は次のレンダリング時に更新されるからのようだ。

参照: State as a Snapshot – React

Stateは独立しPrivateである

次のコードのように、同じ状態変数を持ったコンポーネントを複数使った場合、状態はそれぞれのコンポーネントで独立して変化する。Buttonコンポーネントが二つあり、増やすボタンを押すと押したボタンのIndexのみが増える。(同じ状態変数を使っていて連動しそうにも思えるが、独立していることが分かる)

import { useState } from "react";

  function Button() {
  const [index, setIndex] = useState(0);

  function handleClick() {
    setIndex(index + 1);
    console.log(`index結果: ${index}`); // 0, 1, 2 ...と増える
  }

    return (
      <>
        Index: {index}  {/* 1, 2, 3 ...と増える */}
        <button onClick={handleClick}>
          増やす
        </button>
      </>
    );
  }

  export default function () {
    return (
      <>
        <Button />
        <Button />
      </>
    );
  }

Render and Commit

ドキュメント: Render and Commit – React

レンダリングされるプロセスの理解。

  1. Trigger
  2. Render
  3. Commit

Step 1: Trigger a render

コンポーネントがレンダリングされる状況は2つある:

  • 初期レンダリング
  • コンポーネントの状態が更新された時

初期レンダリング

指定したDOMノードで createRoot を使用してルートオブジェクトを生成し、そのルートオブジェクトの render メソッドを使用してコンポーネントをレンダリングする。

// ドキュメントからそのまま抜粋
import Image from './Image.js';
import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'))
root.render(<Image />);

以前Inertiaを使ったセットアップではエントリーポイントのapp.jsxに次のように記述していた。これでcreateRootの記述の意味が分かった。

// Inertiaの場合
// resources/js/app.jsx
import { createInertiaApp } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'

createInertiaApp({
  resolve: name => {
    const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true })
    return pages[`./Pages/${name}.jsx`]
  },
  setup({ el, App, props }) {
    createRoot(el).render(<App {...props} />)
  },
})

状態更新時

useStateなどのset関数でコンポーネントの状態が更新された時、再度レンダリングされる。

Step 2: Render components

  • レンダリングをトリガーした後、Reactがコンポーネントを呼び出して画面に何を表示するか決める
  • 「レンダリング」とはReactがコンポーネントを呼び出すこと

Step 3: Commits changes to the DOM

  • コンポーネントをレンダリングすると、ReactはDOMを修正する
  • レンダリング間の差分があったDOMノードだけを修正する

差分のみ修正される例はドキュメントのサンプルが分かりやすい: Render and Commit – React 同じコンポーネント内にh1要素とinput要素があるが、状態が更新されたh1要素のみが変更されていることが分かる。

State as a Snapshot

ドキュメント: State as a Snapshot – React

Reactが再レンダリングする時の流れは、

  1. 関数を実行
  2. スナップショットを返す(一時的な、その時点の状態)
  3. 画面を更新(DOMを更新)

例えば、次のようにonClickの処理で一度のクリックイベントの中にsetIndexでインクリメントする処理を3回書いても、実際には一度のクリックで増えるのは3ではなく1だけ。関数が実行完了し次のレンダリングがされるまで見ているのはあくまでスナップショットということだと思う。

// Example for "State as a Snapshot"
import { useState } from "react";

export default function () {
  const [index, setIndex] = useState(0);

    return (
      <>
        Index: {index}.
        <button onClick={() => {
          setIndex(index + 1);
          setIndex(index + 1);
          setIndex(index + 1);
        }}>
          増やす
        </button>
      </>
    );
}

Queueing a Series of State Updates

ドキュメント: Queueing a Series of State Updates – React

前のチャプターで見たsetIndexを3回実行して3つ増やす結果を得るためには、以下のように書き換える。(あまり使わないユースケースかもしれないが)

<button onClick={() => {
  setIndex(n => n + 1);
  setIndex(n => n + 1);
  setIndex(n => n + 1);
}}>

n => n + 1の部分は updater functionと呼ばれる。

Updating Objects in State

ドキュメント: Updating Objects in State – React

ポイント:

  • 状態(State)はオブジェクトも含むJavaScriptのどんな値も持つことができる
  • ただし、オブジェクトを直接変更することはするべきじゃない
  • 代わりにオブジェクトのコピーを用意して状態をセットする

mutationとは?

これまでの例では、数値、文字列、真偽値など、immutable(不変)な値を見てきた。

先の例ではconst [index, setIndex] = useState(0);で状態をセットし、setIndex(index + 1);によって、index0から1に変更した。しかし、0という数字そのものが変わってしまったわけではない。

オブジェクトの場合、次のようにオブジェクトの中身は技術的には変更することが可能(mutable = 可変)。この変化をmutationと呼ぶ。(直訳すると「突然変異」)

import { useState } from "react";

export default function () {
  const [object, setObject] = useState({ x: 0, y:0 });

    return (
      <>
        x: {object.x} {/* 0のまま */}
        <button onClick={() => {
          object.x = 5;
          console.log(object.x); // 5
        }}>
          クリック
        </button>
      </>
    );
}

ただし、オブジェクトも数値や文字列、真偽値のようにimmutableな値として扱うべきである。setObject関数によって、objectは新しいオブジェクトに置き換えられ、このコンポーネントを再度レンダリングする。

import { useState } from "react";

export default function () {
  const [object, setObject] = useState({ x: 0, y:0 });

    return (
      <>
        x: {object.x} {/* 5に変わる setObjectで状態を更新したため */}
        <button onClick={() => {
          setObject({
            x: 5,
            y: 0
        });
          console.log(object.x); // 0
        }}>
          クリック
        </button>
      </>
    );
}

スプレッド構文でオブジェクトをコピー

次に、ユーザー入力フォームを例に考える。以下は、inputボックスに入力した内容を「入力内容欄」に表示したい例。ただし、うまくいかない。オブジェクトはnameemailaddressと3つのプロパティを持っているが、onChangeの中でserUserで状態を更新する時に、他のプロパティを指定していないため。

// 間違った例
import { useState } from "react";

export default function () {
  const [user, setUser] = useState({
    name: '',
    email: '',
    address: '',
  });

  return (
    <>
      <label>
        名前:
        <input type="text"
          value={user.name}
          onChange={(e) => setUser({name: e.target.value})}
        />
      </label>
      <label>
        E-mail:
        <input type="text"
          value={user.email}
          onChange={(e) => setUser({email: e.target.value})}
        />
      </label>
      <label>
        住所:
        <input type="text"
          value={user.address}
          onChange={(e) => setUser({address: e.target.value})}
        />
      </label>

      <div>
        <h2>入力内容</h2>
        <ul>
          <li>名前: {user.name}</li>
          <li>E-mail: {user.email}</li>
          <li>住所: {user.address}</li>
        </ul>
      </div>
    </>
  );
}

以下のように修正。(ただし冗長)

// 修正版
import { useState } from "react";

export default function () {
  const [user, setUser] = useState({
    name: '',
    email: '',
    address: '',
  });

  return (
    <>
      <label>
        名前:
        <input type="text"
          value={user.name}
          onChange={(e) => setUser({
            name: e.target.value,
            email: user.email, // 元の値を維持
            address: user.address, // 元の値を維持
          })}
        />
      </label>
      <label>
        E-mail:
        <input type="text"
          value={user.email}
          onChange={(e) => setUser({
            email: e.target.value,
            name: user.name, // 元の値を維持
            address: user.address, // 元の値を維持
          })}
        />
      </label>
      <label>
        住所:
        <input type="text"
          value={user.address}
          onChange={(e) => setUser({
            address: e.target.value,
            name: user.name, // 元の値を維持
            email: user.email, // 元の値を維持
          })}
        />
      </label>

      <div>
        <h2>入力内容</h2>
        <ul>
          <li>名前: {user.name}</li>
          <li>E-mail: {user.email}</li>
          <li>住所: {user.address}</li>
        </ul>
      </div>
    </>
  );
}

オブジェクトのスプレッド構文(...)を使って改良。

// 他の部分は省略
<label>
  名前:
  <input type="text"
    value={user.name}
    onChange={(e) => setUser({
      ...user, // 元の値をコピー
      name: e.target.value, // overrideする
    })}
  />
</label>
<label>
  E-mail:
  <input type="text"
    value={user.email}
    onChange={(e) => setUser({
      ...user, // 元の値をコピー
      email: e.target.value, // overrideする
    })}
  />
</label>
<label>
  住所:
  <input type="text"
    value={user.address}
    onChange={(e) => setUser({
      ...user, // 元の値をコピー
      address: e.target.value, // overrideする
    })}
  />
</label>

Updating Arrays in State

ドキュメント: Updating Arrays in State – React

オブジェクトと同様に、配列はmutableだがimmutableとして扱うべき。元の配列を操作するではなく、新しい配列を作って操作すること。配列の扱いについて分かりやすい表があったため参照:

🆖 avoid (mutates the array) 👍 prefer (returns a new array)
adding pushunshift concat[...arr] spread syntax
removing popshiftsplice filterslice
replacing splicearr[i] = ... assignment map
sorting reversesort copy the array first

連載記事

  1. 【1日目】React初学者が1週間でどこまで出来るか挑戦
  2. 【2日目】React初学者が1週間でどこまで出来るか挑戦(ひたすらドキュメント編)
  3. 【3日目】React初学者が1週間でどこまで出来るか挑戦(Inertiaの理解)
  4. 【4日目】React初学者が1週間でどこまで出来るか挑戦(Createフォーム作成)
  5. 【5日目】React初学者が1週間でどこまで出来るか挑戦(Createフォーム改善)
  6. 【6日目】React初学者が1週間でどこまで出来るか挑戦(イベントとState)