Engineering from Scratch

エンジニア目指してます

Choosing the State Structure

state の構造を考えるときに,考慮すべき原則は以下。

  1. 関連する state をグループ化する。 二つ以上の state を常に同時に更新しているなら,それらの state を単一の state に統合することを考えるべき。
  2. state 間での矛盾を避ける。
  3. 冗長な state を避ける。 props や既存の state から算出できる情報を新たに state で管理すべきではない。
  4. 重複した state を避ける。
  5. 深くネストした state を避ける。 深い階層の state は更新がしにくい。
import { useState } from "react";

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

  return (
    <div
      onPointerMove={(e) => setPosition({ x: e.clientX, y: e.clientY })}
      style={{
        position: "relative",
        width: "100vw",
        height: "100vh",
      }}
    >
      <div
        style={{
          position: "absolute",
          backgroundColor: "red",
          borderRadius: "50%",
          transform: `translate(${position.x}px, ${position.y}px)`,
          left: -10,
          top: -10,
          width: 20,
          height: 20,
        }}
      />
    </div>
  );
}

export default App;

positionx,yプロパティは常に同時に更新されるため,単一の state で管理した方がよい。

Avoid contradictions in state

import { FormEvent, useState } from "react";

function App() {
  const [text, setText] = useState("");
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setIsSending(true);
    await sendMessage(text);
    setIsSending(false);
    setIsSent(true);
  }

  if (isSent) {
    return <h1>Thanks for feedback!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>How was your stay at The Pricing Pony?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <br />
      <button disabled={isSending} type="submit">
        Send
      </button>
      {isSending && <p>Sending...</p>}
    </form>
  );
}

function sendMessage(text: string): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, 2000);
  });
}

export default App;

上記のコードだと,isSendingisSentが両方とも true という矛盾した state が存在する可能性がある。statusというユニオン型を持つ state を導入することで解決できる。

import { FormEvent, useState } from "react";

function App() {
  const [text, setText] = useState("");
  const [status, setStatus] =
    (useState < "typing") | "sending" | ("sent" > "typing");

  async function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");
    await sendMessage(text);
    setStatus("sent");
  }

  const isSending = status === "sending";
  const isSent = status === "sent";

  if (isSent) {
    return <h1>Thanks for feedback!</h1>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>How was your stay at The Pricing Pony?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <br />
      <button disabled={isSending} type="submit">
        Send
      </button>
      {isSending && <p>Sending...</p>}
    </form>
  );
}

function sendMessage(text: string): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve, 2000);
  });
}

export default App;

typing,sending,sentというユニオン型を持つstatusstate を定義することで,矛盾した state が発生しなくなった。

Avoid redundant state

import { ChangeEvent, FormEvent, useState } from "react";

function App() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastNaem] = useState("");
  const [fullName, setFullName] = useState("");

  function handleFirstNameChange(e: ChangeEvent<HTMLInputElement>) {
    setFirstName(e.target.value);
    setFullName(e.target.value + " " + lastName);
  }

  function handleLastNameChange(e: ChangeEvent<HTMLInputElement>) {
    setLastNaem(e.target.value);
    setFullName(firstName + " " + e.target.value);
  }

  return (
    <>
      <h2>Let's check you in</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

export default App;

fullNamefirstNamelastNameから算出可能な値であり,冗長な state となっている。

以下のように,fullName = firstName + ' ' + lastNamefullNameを直接算出すれば良い。

import { ChangeEvent, useState } from "react";

function App() {
  const [firstName, setFirstName] = useState("");
  const [lastName, setLastName] = useState("");

  const fullName = firstName + " " + lastName;

  function handleFirstNameChange(e: ChangeEvent<HTMLInputElement>) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e: ChangeEvent<HTMLInputElement>) {
    setLastName(e.target.value);
  }

  return (
    <>
      <h2>Let's check you in</h2>
      <label>
        First name: <input value={firstName} onChange={handleFirstNameChange} />
      </label>
      <label>
        Last name: <input value={lastName} onChange={handleLastNameChange} />
      </label>
      <p>
        Your ticket will be issued to: <b>{fullName}</b>
      </p>
    </>
  );
}

export default App;

Don't mirror props in state

import { useState } from "react";

function App() {
  const [firstName, setFirstName] = useState("");

  return (
    <>
      <label>Parent First Name</label>
      <input onChange={(e) => setFirstName(e.target.value)} />
      <label>Child First Name</label>
      <FirstName firstName={firstName} />
    </>
  );
}

type FirstNameProps = {
  firstName: string,
};

function FirstName({ firstName }: FirstNameProps) {
  const [name, setName] = useState(firstName);

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

export default App;

上記のFirstNameコンポーネントは,firstNameを props として受け取り,useState の引数として受け取っている。しかし,Appコンポーネント内のfirstNameが更新され,FirstNameコンポーネントfirstNameprops が更新されても,useState の値は更新されない。なぜなら,useState の初期化は最初のレンダリングのみで行われるため。

Avoid duplication in state

import { ChangeEvent, useState } from "react";

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

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

  function handleItemChange(id: number, e: ChangeEvent<HTMLInputElement>) {
    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) => (
          <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>
    </>
  );
}

export default App;

上記の実装だと,selectedItemitems内に存在するitemで重複が生まれてしまう。そのため,itemsに変更が加えられても,selectedItemに反映されない。selectedIdという state を持つことで state の重複を回避できる。

import { ChangeEvent, useState } from "react";

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

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

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

  function handleItemChange(id: number, e: ChangeEvent<HTMLInputElement>) {
    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) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={(e) => handleItemChange(item.id, e)}
            />{" "}
            <button
              onClick={() => {
                setSelectedId(item.id);
              }}
            >
              Choose
            </button>
          </li>
        ))}
      </ul>
      {selectedItem && <p>You picked {selectedItem.title}</p>}
    </>
  );
}

export default App;

Avoid deeply nested state

import { ChangeEvent, useState } from "react";

const initialTravelPlan = {
  id: 0,
  title: "(Root)",
  childPlaces: [
    {
      id: 1,
      title: "Earth",
      childPlaces: [
        {
          id: 2,
          title: "Africa",
          childPlaces: [
            {
              id: 3,
              title: "Botswana",
              childPlaces: [],
            },
            {
              id: 4,
              title: "Egypt",
              childPlaces: [],
            },
            {
              id: 5,
              title: "Kenya",
              childPlaces: [],
            },
            {
              id: 6,
              title: "Madagascar",
              childPlaces: [],
            },
            {
              id: 7,
              title: "Morocco",
              childPlaces: [],
            },
            {
              id: 8,
              title: "Nigeria",
              childPlaces: [],
            },
            {
              id: 9,
              title: "South Africa",
              childPlaces: [],
            },
          ],
        },
        {
          id: 10,
          title: "Americas",
          childPlaces: [
            {
              id: 11,
              title: "Argentina",
              childPlaces: [],
            },
            {
              id: 12,
              title: "Brazil",
              childPlaces: [],
            },
            {
              id: 13,
              title: "Barbados",
              childPlaces: [],
            },
            {
              id: 14,
              title: "Canada",
              childPlaces: [],
            },
            {
              id: 15,
              title: "Jamaica",
              childPlaces: [],
            },
            {
              id: 16,
              title: "Mexico",
              childPlaces: [],
            },
            {
              id: 17,
              title: "Trinidad and Tobago",
              childPlaces: [],
            },
            {
              id: 18,
              title: "Venezuela",
              childPlaces: [],
            },
          ],
        },
        {
          id: 19,
          title: "Asia",
          childPlaces: [
            {
              id: 20,
              title: "China",
              childPlaces: [],
            },
            {
              id: 21,
              title: "Hong Kong",
              childPlaces: [],
            },
            {
              id: 22,
              title: "India",
              childPlaces: [],
            },
            {
              id: 23,
              title: "Singapore",
              childPlaces: [],
            },
            {
              id: 24,
              title: "South Korea",
              childPlaces: [],
            },
            {
              id: 25,
              title: "Thailand",
              childPlaces: [],
            },
            {
              id: 26,
              title: "Vietnam",
              childPlaces: [],
            },
          ],
        },
        {
          id: 27,
          title: "Europe",
          childPlaces: [
            {
              id: 28,
              title: "Croatia",
              childPlaces: [],
            },
            {
              id: 29,
              title: "France",
              childPlaces: [],
            },
            {
              id: 30,
              title: "Germany",
              childPlaces: [],
            },
            {
              id: 31,
              title: "Italy",
              childPlaces: [],
            },
            {
              id: 32,
              title: "Portugal",
              childPlaces: [],
            },
            {
              id: 33,
              title: "Spain",
              childPlaces: [],
            },
            {
              id: 34,
              title: "Turkey",
              childPlaces: [],
            },
          ],
        },
        {
          id: 35,
          title: "Oceania",
          childPlaces: [
            {
              id: 36,
              title: "Australia",
              childPlaces: [],
            },
            {
              id: 37,
              title: "Bora Bora (French Polynesia)",
              childPlaces: [],
            },
            {
              id: 38,
              title: "Easter Island (Chile)",
              childPlaces: [],
            },
            {
              id: 39,
              title: "Fiji",
              childPlaces: [],
            },
            {
              id: 40,
              title: "Hawaii (the USA)",
              childPlaces: [],
            },
            {
              id: 41,
              title: "New Zealand",
              childPlaces: [],
            },
            {
              id: 42,
              title: "Vanuatu",
              childPlaces: [],
            },
          ],
        },
      ],
    },
    {
      id: 43,
      title: "Moon",
      childPlaces: [
        {
          id: 44,
          title: "Rheita",
          childPlaces: [],
        },
        {
          id: 45,
          title: "Piccolomini",
          childPlaces: [],
        },
        {
          id: 46,
          title: "Tycho",
          childPlaces: [],
        },
      ],
    },
    {
      id: 47,
      title: "Mars",
      childPlaces: [
        {
          id: 48,
          title: "Corn Town",
          childPlaces: [],
        },
        {
          id: 49,
          title: "Green Hill",
          childPlaces: [],
        },
      ],
    },
  ],
};

type PlaceTreeProps = {
  id: number,
  title: string,
  childPlaces: PlaceTreeProps[],
};

function PlaceTree({ id, title, childPlaces }: PlaceTreeProps) {
  return (
    <>
      <li>{title}</li>
      {childPlaces.length > 0 && (
        <ol>
          {childPlaces.map((place) => (
            <PlaceTree
              key={place.id}
              id={place.id}
              title={place.title}
              childPlaces={place.childPlaces}
            />
          ))}
        </ol>
      )}
    </>
  );
}

function App() {
  const [plan, setPlan] = useState(initialTravelPlan);
  const planets = plan.childPlaces;

  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {planets.map((place) => (
          <PlaceTree
            key={place.id}
            id={place.id}
            title={place.title}
            childPlaces={place.childPlaces}
          />
        ))}
      </ol>
    </>
  );
}

export default App;

上記のような実装だと,ネストが深く更新が行いにくい。そのため,構造を以下のようにフラットにする。

import { useState } from "react";

type Plan = { id: number, title: string, childIds: number[] };

type TravelPlan = {
  [key: number]: Plan,
};

const initialTravelPlan: TravelPlan = {
  0: {
    id: 0,
    title: "(Root)",
    childIds: [1, 43, 47],
  },
  1: {
    id: 1,
    title: "Earth",
    childIds: [2, 10, 19, 27, 35],
  },
  2: {
    id: 2,
    title: "Africa",
    childIds: [3, 4, 5, 6, 7, 8, 9],
  },
  3: {
    id: 3,
    title: "Botswana",
    childIds: [],
  },
  4: {
    id: 4,
    title: "Egypt",
    childIds: [],
  },
  5: {
    id: 5,
    title: "Kenya",
    childIds: [],
  },
  6: {
    id: 6,
    title: "Madagascar",
    childIds: [],
  },
  7: {
    id: 7,
    title: "Morocco",
    childIds: [],
  },
  8: {
    id: 8,
    title: "Nigeria",
    childIds: [],
  },
  9: {
    id: 9,
    title: "South Africa",
    childIds: [],
  },
  10: {
    id: 10,
    title: "Americas",
    childIds: [11, 12, 13, 14, 15, 16, 17, 18],
  },
  11: {
    id: 11,
    title: "Argentina",
    childIds: [],
  },
  12: {
    id: 12,
    title: "Brazil",
    childIds: [],
  },
  13: {
    id: 13,
    title: "Barbados",
    childIds: [],
  },
  14: {
    id: 14,
    title: "Canada",
    childIds: [],
  },
  15: {
    id: 15,
    title: "Jamaica",
    childIds: [],
  },
  16: {
    id: 16,
    title: "Mexico",
    childIds: [],
  },
  17: {
    id: 17,
    title: "Trinidad and Tobago",
    childIds: [],
  },
  18: {
    id: 18,
    title: "Venezuela",
    childIds: [],
  },
  19: {
    id: 19,
    title: "Asia",
    childIds: [20, 21, 22, 23, 24, 25, 26],
  },
  20: {
    id: 20,
    title: "China",
    childIds: [],
  },
  21: {
    id: 21,
    title: "Hong Kong",
    childIds: [],
  },
  22: {
    id: 22,
    title: "India",
    childIds: [],
  },
  23: {
    id: 23,
    title: "Singapore",
    childIds: [],
  },
  24: {
    id: 24,
    title: "South Korea",
    childIds: [],
  },
  25: {
    id: 25,
    title: "Thailand",
    childIds: [],
  },
  26: {
    id: 26,
    title: "Vietnam",
    childIds: [],
  },
  27: {
    id: 27,
    title: "Europe",
    childIds: [28, 29, 30, 31, 32, 33, 34],
  },
  28: {
    id: 28,
    title: "Croatia",
    childIds: [],
  },
  29: {
    id: 29,
    title: "France",
    childIds: [],
  },
  30: {
    id: 30,
    title: "Germany",
    childIds: [],
  },
  31: {
    id: 31,
    title: "Italy",
    childIds: [],
  },
  32: {
    id: 32,
    title: "Portugal",
    childIds: [],
  },
  33: {
    id: 33,
    title: "Spain",
    childIds: [],
  },
  34: {
    id: 34,
    title: "Turkey",
    childIds: [],
  },
  35: {
    id: 35,
    title: "Oceania",
    childIds: [36, 37, 38, 39, 40, 41, 42],
  },
  36: {
    id: 36,
    title: "Australia",
    childIds: [],
  },
  37: {
    id: 37,
    title: "Bora Bora (French Polynesia)",
    childIds: [],
  },
  38: {
    id: 38,
    title: "Easter Island (Chile)",
    childIds: [],
  },
  39: {
    id: 39,
    title: "Fiji",
    childIds: [],
  },
  40: {
    id: 40,
    title: "Hawaii (the USA)",
    childIds: [],
  },
  41: {
    id: 41,
    title: "New Zealand",
    childIds: [],
  },
  42: {
    id: 42,
    title: "Vanuatu",
    childIds: [],
  },
  43: {
    id: 43,
    title: "Moon",
    childIds: [44, 45, 46],
  },
  44: {
    id: 44,
    title: "Rheita",
    childIds: [],
  },
  45: {
    id: 45,
    title: "Piccolomini",
    childIds: [],
  },
  46: {
    id: 46,
    title: "Tycho",
    childIds: [],
  },
  47: {
    id: 47,
    title: "Mars",
    childIds: [48, 49],
  },
  48: {
    id: 48,
    title: "Corn Town",
    childIds: [],
  },
  49: {
    id: 49,
    title: "Green Hill",
    childIds: [],
  },
};

type PlaceTreeProps = {
  id: number,
  parentId: number,
  placesById: TravelPlan,
  onComplete: (parentId: number, id: number) => void,
};

function PlaceTree({ id, parentId, placesById, onComplete }: PlaceTreeProps) {
  const place = placesById[id];
  const childIds = place.childIds;

  return (
    <>
      <li>
        {place.title}
        <button onClick={() => onComplete(parentId, id)}>Complete</button>
      </li>
      {childIds.length > 0 && (
        <ol>
          {childIds.map((childId) => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      )}
    </>
  );
}

function App() {
  const [plan, setPlan] = useState(initialTravelPlan);
  const root = plan[0];
  const planetIds = root.childIds;

  function handleComplete(parentId: number, childId: number) {
    const parent = plan[parentId];
    const nextParent: Plan = {
      ...parent,
      childIds: parent.childIds.filter((id) => id !== childId),
    };

    setPlan({
      ...plan,
      [parentId]: nextParent,
    });
  }

  return (
    <>
      <h2>Places to visit</h2>
      <ol>
        {planetIds.map((id) => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

export default App;