📘

React勉強しなおしてみた #4

2024/04/24に公開

前回記事はこちら

本記事の内容

元JavaエンジニアがReactを再学習する記録。
本記事内では下記セクションを学習する。

  • stateの管理

stateの管理

公式学習ページ
#1作成したプロジェクトを引き続き使用して学習していく。

stateを使って入力に反応する

Reactを使う場合、「ボタンを無効/有効にする」「メッセージを表示する」などコードから欲説UIを変更することはない。代わりにコンポーネントの状態に対して表示したいUIを記述し、ユーザの入力に応じてstateの変更をトリガする。
以下の例は3がつく数字と3で割り切れる数字だけ( ᐛ )という顔文字が表示されるようCounterを改造したもの。foolの値によってpタグの表示が切り替わるようになっている。

src\components\Button\MyButton.tsx
export function Counter() {
  const [count, setCount] = useState(0);
  const [fool, setFool] = useState(false);
  return (
    <>
      <button
        onClick={() => {
          const nextCount = count + 1;
          setCount(nextCount);
          setFool(String(nextCount).indexOf("3") > 0 || nextCount % 3 === 0);
        }}
      >
        {count}
      </button>
      {fool && <p>()</p>}
    </>
  );
}

state構造の選択

stateをうまく構造化できていないとバグの温床になる。最も重要な原則はstateに冗長な情報、重複した情報を持たせないこと。
下記は冗長なstateが存在する例。

src\components\form\MyForm.tsx
export default function MyForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");
  const [fullName, setFullName] = useState("");

  function handleFirstNameChange(value: string) {
    setFirstName(value);
    setFullName(value + " " + lastName);
  }

  function handleLastNameChange(value: string) {
    setLastName(value);
    setFullName(firstName + " " + value);
  }

  return (
    <ul>
      <li>
        <label>
          First name:
          <input value={firstName} onChange={(e) => handleFirstNameChange(e.target.value)} />
        </label>
      </li>
      <li>
        <label>
          Last name:
          <input value={lastName} onChange={(e) => handleLastNameChange(e.target.value)} />
        </label>
      </li>
      <li>
        <p>Full name: {fullName}</p>
      </li>
    </ul>
  );
}

fullNameはstateで管理せず、firstName, lastNameから計算させればいいので、fullNameを削除する。これですっきりした。

src\components\form\MyForm.tsx
export default function MyForm() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  function handleFirstNameChange(value: string) {
    setFirstName(value);
  }

  function handleLastNameChange(value: string) {
    setLastName(value);
  }

  return (
    <ul>
      <li>
        <label>
          First name:
          <input value={firstName} onChange={(e) => handleFirstNameChange(e.target.value)} />
        </label>
      </li>
      <li>
        <label>
          Last name:
          <input value={lastName} onChange={(e) => handleLastNameChange(e.target.value)} />
        </label>
      </li>
      <li>
        <p>Full name: {`${firstName} ${lastName}`}</p>
      </li>
    </ul>
  );
}

コンポーネント間でstateを共有する

2つのコンポーネントのstateを常に同時に変更したい場合、stateは各コンポーネントではなく最も近い共通の親コンポーネントで管理し、そこからstateを各コンポーネントへ渡す。
例はクリックしたいずれか一つのパネルがアクティブになるコンポーネント。パネル自体ではstateは管理せず、親でアクティブなパネルのindexをstateとして管理している。

src\components\flag\MyFlags.tsx
export default function MyPanels() {
  const colors = ["red", "yellow", "blue"];
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <div style={{ display: "flex", gap: "10px" }}>
      {colors.map((color, index) => (
        <MyPanel
          key={color}
          color={color}
          isActive={index === activeIndex}
          onClick={() => setActiveIndex(index)}
        />
      ))}
    </div>
  );
}

type MyPanelProps = {
  color: string;
  isActive: boolean;
  onClick: () => void;
};

function MyPanel({ color, isActive, onClick }: MyPanelProps) {
  return (
    <div
      style={{
        width: "200px",
        height: "120px",
        background: color,
        opacity: isActive ? 1 : 0.2,
      }}
      onClick={onClick}
    ></div>
  );
}

stateの保持とリセット

コンポーネントを再レンダーする際、Reactはツリーのどの部分を保持(あるいは更新)し、どの部分を破棄または最初から作成するかを決定する必要がある。デフォルトではReactは以前にレンダーされたコンポーネントツリーと一致する部分のツリーを保持する。
ただし、場合によっては望ましくない場合がある。公式ページではチャットアプリが例に挙げられている。

src\components\messenger\Messenger.tsx
type Contact = {
  name: string;
  email: string;
};

const contacts: Contact[] = [
  { name: "Taylor", email: "taylor@mail.com" },
  { name: "Alice", email: "alice@mail.com" },
  { name: "Bob", email: "bob@mail.com" },
];

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={(contact) => setTo(contact)}
      />
      <Chat contact={to} />
    </div>
  );
}

type ContactListProps = {
  selectedContact: Contact;
  contacts: Contact[];
  onSelect: (contact: Contact) => void;
};

function ContactList({ selectedContact, contacts, onSelect }: ContactListProps) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map((contact) => (
          <li key={contact.email}>
            <button
              onClick={() => {
                onSelect(contact);
              }}
            >
              {contact.name}
            </button>
          </li>
        ))}
      </ul>
    </section>
  );
}

type ChatProps = {
  contact: Contact;
};

function Chat({ contact }: ChatProps) {
  const [text, setText] = useState("");
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={"Chat to " + contact.name}
        onChange={(e) => setText(e.target.value)}
      />
      <br />
      <button>Send to {contact.email}</button>
    </section>
  );
}

このままではメッセージを入力した後、宛先を切り替えても入力欄がリセットされない。これを解決するには<Chat key={to.email} contact={to} />としてしまえばいい。これによって送信先が異なる場合は別のChatコンポーネントであり再作成する必要があるとReactに伝えることができる。

stateロジックをリデューサに抽出する

多くのイベントハンドラにまたがってstateの更新コードが含まれるコンポーネントは理解しづらい。その場合、コンポーネントの外部にreducerと呼ばれる単一の関数を作成し、すべてのstate更新ロジックを集約することができる。

src\components\task\MyTask.tsx
type Task = {
  id: number;
  text: string;
  done: boolean;
};

const initialTasks: Task[] = [
  { id: 0, text: "Visit Kafka Museum", done: true },
  { id: 1, text: "Watch a puppet show", done: false },
  { id: 2, text: "Lennon Wall pic", done: false },
];

type Action =
  | { type: "added"; payload: { id: number; text: string } }
  | { type: "changed"; payload: { task: Task } }
  | { type: "deleted"; payload: { id: number } };

function tasksReducer(tasks: Task[], action: Action) {
  switch (action.type) {
    case "added": {
      return [
        ...tasks,
        {
          id: action.payload.id,
          text: action.payload.text,
          done: false,
        },
      ];
    }
    case "changed": {
      return tasks.map((t) => {
        if (t.id === action.payload.task.id) {
          return action.payload.task;
        } else {
          return t;
        }
      });
    }
    case "deleted": {
      return tasks.filter((t) => t.id !== action.payload.id);
    }
  }
}

let nextId = 3;
export default function MyTask() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);

  function handleAddTask(text: string) {
    dispatch({
      type: "added",
      payload: {
        id: nextId++,
        text: text,
      },
    });
  }

  function handleChangeTask(task: Task) {
    dispatch({
      type: "changed",
      payload: {
        task: task,
      },
    });
  }

  function handleDeleteTask(taskId: number) {
    dispatch({
      type: "deleted",
      payload: {
        id: taskId,
      },
    });
  }

  return (
    <>
      <h1>Prague itinerary</h1>
      <AddTask onAddTask={handleAddTask} />
      <TaskList tasks={tasks} onChangeTask={handleChangeTask} onDeleteTask={handleDeleteTask} />
    </>
  );
}

type AddTaskProps = {
  onAddTask: (text: string) => void;
};

function AddTask({ onAddTask }: AddTaskProps) {
  const [text, setText] = useState("");

  function handleChange(text: string) {
    setText(text);
  }

  function handleAdd() {
    onAddTask(text);
    setText("");
  }

  return (
    <div>
      <input onChange={(e) => handleChange(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
    </div>
  );
}

type TaskListProps = {
  tasks: Task[];
  onChangeTask: (task: Task) => void;
  onDeleteTask: (id: number) => void;
};

function TaskList({ tasks, onChangeTask, onDeleteTask }: TaskListProps) {
  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem
          key={task.id}
          task={task}
          onChangeTask={onChangeTask}
          onDeleteTask={onDeleteTask}
        />
      ))}
    </ul>
  );
}

type TaskItemProps = {
  task: Task;
  onChangeTask: (task: Task) => void;
  onDeleteTask: (id: number) => void;
};
function TaskItem({ task, onChangeTask, onDeleteTask }: TaskItemProps) {
  const [isEditMode, setEditMode] = useState(false);
  const [text, setText] = useState(task.text);

  function handleChangeDone() {
    onChangeTask({ ...task, done: !task.done });
  }

  function handleChangeText(text: string) {
    setText(text);
  }

  function handleChangeEditMode() {
    setEditMode(true);
  }

  function handleSave() {
    onChangeTask({ ...task, text });
    setEditMode(false);
  }

  function handleDelete() {
    onDeleteTask(task.id);
  }

  return (
    <li
      onClick={handleChangeDone}
      style={{ display: "grid", gridTemplateColumns: "200px 50px 50px", gap: "5px" }}
    >
      {isEditMode ? (
        <>
          <input value={text} onChange={(e) => handleChangeText(e.target.value)} />
          <button onClick={handleSave}>Save</button>
        </>
      ) : (
        <>
          <p>{text}</p>
          <button onClick={handleChangeEditMode}>Edit</button>
        </>
      )}
      <button onClick={handleDelete}>Delete</button>
    </li>
  );
}

コンテクストで深くデータを受け渡す

通常、親コンポーネントから子コンポーネントにはpropsを使って情報を渡す。しかし、propsを多数の中間コンポーネントを経由して渡さないといけない場合や、アプリ内の多くのコンポーネントが同じ情報を必要とする場合、propsの受け渡しは冗長で不便である。Contextを使用することで親コンポーネントからpropsを明示的に渡さずとも、それ以下のツリー内の任意のコンポーネントが情報を受け取れるようにできる。
以下は公式ページに記載の例。Headingコンポーネントは最も近いSectionに自身のネストレベルを尋ねることで見出しのレベルを決定している。Sectionは親のレベルに1を加えることでレベルを把握する。すべてのSectionはpropsを渡さずにそれ以下のすべてのコンポーネントにコンテクストを通じて情報を渡している。

src\components\page\Page.tsx
import { useContext, createContext } from "react";

const LevelContext = createContext(0);

export default function Page() {
  return (
    <Section>
      <Heading>Title</Heading>
      <Section>
        <Heading>Heading</Heading>
        <Heading>Heading</Heading>
        <Heading>Heading</Heading>
        <Section>
          <Heading>Sub-heading</Heading>
          <Heading>Sub-heading</Heading>
          <Heading>Sub-heading</Heading>
          <Section>
            <Heading>Sub-sub-heading</Heading>
            <Heading>Sub-sub-heading</Heading>
            <Heading>Sub-sub-heading</Heading>
          </Section>
        </Section>
      </Section>
    </Section>
  );
}

function Section({ children }: { children: React.ReactNode }) {
  const level = useContext(LevelContext);
  return (
    <section className="section">
      <LevelContext.Provider value={level + 1}>{children}</LevelContext.Provider>
    </section>
  );
}

function Heading({ children }: { children: React.ReactNode }) {
  const level = useContext(LevelContext);
  switch (level) {
    case 0:
      throw Error("Heading must be inside a Section!");
    case 1:
      return <h1>{children}</h1>;
    case 2:
      return <h2>{children}</h2>;
    case 3:
      return <h3>{children}</h3>;
    case 4:
      return <h4>{children}</h4>;
    case 5:
      return <h5>{children}</h5>;
    case 6:
      return <h6>{children}</h6>;
    default:
      throw Error("Unknown level: " + level);
  }
}

リデューサとコンテクストでスケールアップ

Reducerでコンポーネントのstate更新ロジックを集約し、Contextで他のコンポーネントに深さ関係なく情報を渡すことができた。そして、この二つを組み合わせることで複雑な画面のstate管理ができるようになる。
先ほど作成したTaskを改造してみる。これでほとんどpropsの受け渡しが必要なくなった。

src\components\task\type.ts
export type Task = {
  id: number;
  text: string;
  done: boolean;
};

export type Action =
  | { type: "added"; payload: { id: number; text: string } }
  | { type: "changed"; payload: { task: Task } }
  | { type: "deleted"; payload: { id: number } };
src\components\task\TaskContext.tsx
import { createContext, useContext, useReducer } from "react";
import { Action, Task } from "./type";

const initialTasks: Task[] = [
  { id: 0, text: "Visit Kafka Museum", done: true },
  { id: 1, text: "Watch a puppet show", done: false },
  { id: 2, text: "Lennon Wall pic", done: false },
];

const TasksContext = createContext(initialTasks);
const TasksDispatchContext = createContext((() => true) as React.Dispatch<Action>);

function tasksReducer(tasks: Task[], action: Action) {
  console.log(action);

  switch (action.type) {
    case "added": {
      return [
        ...tasks,
        {
          id: action.payload.id,
          text: action.payload.text,
          done: false,
        },
      ];
    }
    case "changed": {
      return tasks.map((t) => {
        if (t.id === action.payload.task.id) {
          return action.payload.task;
        } else {
          return t;
        }
      });
    }
    case "deleted": {
      return tasks.filter((t) => t.id !== action.payload.id);
    }
  }
}

type TasksProvidesProps = {
  children: React.ReactNode;
};

export function TasksProvider({ children }: TasksProvidesProps) {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  return (
    <TasksContext.Provider value={tasks}>
      <TasksDispatchContext.Provider value={dispatch}>{children}</TasksDispatchContext.Provider>
    </TasksContext.Provider>
  );
}

export function useTasks() {
  return useContext(TasksContext);
}

export function useTasksDispatch() {
  return useContext(TasksDispatchContext);
}
src\components\task\MyTask.tsx
import AddTask from "./AddTask";
import { TasksProvider } from "./TaskContext";
import TaskList from "./TaskList";

export default function MyTask() {
  return (
    <TasksProvider>
      <h1>Prague itinerary</h1>
      <AddTask />
      <TaskList />
    </TasksProvider>
  );
}
src\components\task\TaskList.tsx
import { useState } from "react";
import { useTasks, useTasksDispatch } from "./TaskContext";
import { Task } from "./type";

export default function TaskList() {
  const tasks = useTasks();
  return (
    <ul>
      {tasks.map((task) => (
        <TaskItem key={task.id} task={task} />
      ))}
    </ul>
  );
}

type TaskItemProps = {
  task: Task;
};

function TaskItem({ task }: TaskItemProps) {
  const [isEditMode, setEditMode] = useState(false);
  const [text, setText] = useState(task.text);
  const dispatch = useTasksDispatch();

  function handleChangeDone() {
    dispatch({
      type: "changed",
      payload: {
        task: { ...task, done: !task.done },
      },
    });
  }

  function handleChangeText(text: string) {
    setText(text);
  }

  function handleChangeEditMode() {
    setEditMode(true);
  }

  function handleSave() {
    dispatch({
      type: "changed",
      payload: {
        task: { ...task, text },
      },
    });
    setEditMode(false);
  }

  function handleDelete() {
    dispatch({
      type: "deleted",
      payload: {
        id: task.id,
      },
    });
  }

  return (
    <li
      onClick={handleChangeDone}
      style={{ display: "grid", gridTemplateColumns: "200px 50px 50px", gap: "5px" }}
    >
      {isEditMode ? (
        <>
          <input value={text} onChange={(e) => handleChangeText(e.target.value)} />
          <button onClick={handleSave}>Save</button>
        </>
      ) : (
        <>
          <p>{text}</p>
          <button onClick={handleChangeEditMode}>Edit</button>
        </>
      )}
      <button onClick={handleDelete}>Delete</button>
    </li>
  );
}
src\components\task\AddTask.tsx
import { useState } from "react";
import { useTasksDispatch } from "./TaskContext";

let nextId = 3;

export default function AddTask() {
  const [text, setText] = useState("");
  const dispatch = useTasksDispatch();

  function handleChange(text: string) {
    setText(text);
  }

  function handleAdd() {
    dispatch({
      type: "added",
      payload: {
        id: nextId++,
        text: text,
      },
    });
    setText("");
  }

  return (
    <div>
      <input onChange={(e) => handleChange(e.target.value)} />
      <button onClick={handleAdd}>Add</button>
    </div>
  );
}

Discussion