React勉強しなおしてみた #4
前回記事はこちら
本記事の内容
元JavaエンジニアがReactを再学習する記録。
本記事内では下記セクションを学習する。
- stateの管理
stateの管理
公式学習ページ
#1作成したプロジェクトを引き続き使用して学習していく。
stateを使って入力に反応する
Reactを使う場合、「ボタンを無効/有効にする」「メッセージを表示する」などコードから欲説UIを変更することはない。代わりにコンポーネントの状態に対して表示したいUIを記述し、ユーザの入力に応じてstateの変更をトリガする。
以下の例は3がつく数字と3で割り切れる数字だけ( ᐛ )という顔文字が表示されるようCounter
を改造したもの。fool
の値によってp
タグの表示が切り替わるようになっている。
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が存在する例。
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
を削除する。これですっきりした。
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として管理している。
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は以前にレンダーされたコンポーネントツリーと一致する部分のツリーを保持する。
ただし、場合によっては望ましくない場合がある。公式ページではチャットアプリが例に挙げられている。
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更新ロジックを集約することができる。
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を渡さずにそれ以下のすべてのコンポーネントにコンテクストを通じて情報を渡している。
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の受け渡しが必要なくなった。
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 } };
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);
}
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>
);
}
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>
);
}
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