0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜuseStateは配列を返すのか。Reactの「地味な文法」に隠れた設計判断を4つ掘る

0
Posted at

ここまで4回、Reactの「中身」を掘ってきました。仮想DOM、diff、useStateの実装、バッチ処理。今回は少し毛色を変えて、「なぜこの書き方なのか」を、文法そのものに絞って4つ深掘りします。

  • なぜuseState{value, setValue}じゃなく[value, setValue]という配列を返すのか
  • なぜJSXの属性はclassNameonClickみたいなcamelCaseなのか
  • なぜコンポーネントは単一のルート要素しか返せないのか
  • なぜHooksはuseから始まる関数呼び出しという形なのか

「そういうもの」で流していた文法1つ1つに、実はちゃんと理由があります。順番に見ていきます。


なぜuseStateは配列を返すのか

const [count, setCount] = useState(0);

これは配列の分割代入です。もしuseStateがオブジェクトを返していたら、こう書くことになります。

const {value: count, setValue: setCount} = useState(0);

……急に面倒になりました。理由は、オブジェクトの分割代入はプロパティ名で値を取り出すからです。同じコンポーネント内でuseStateをもう1回使う場面を考えます。

// オブジェクトを返す仮想のuseStateだったら
const {value, setValue} = useState(0);   // count用のつもり
const {value, setValue} = useState("");  // ← 同じ名前とぶつかる

valuesetValueという名前が毎回同じなので、2回目を呼んだ瞬間に名前が衝突します。避けるには毎回リネームするしかありません。

const {value: count, setValue: setCount} = useState(0);
const {value: text, setValue: setText} = useState("");

一方、配列の分割代入は**「何番目か」**で値を取り出します。名前は受け取る側が完全に自由に決められるので、衝突しようがありません。

const [count, setCount] = useState(0);
const [text, setText] = useState("");

これが配列を返す本当の理由です。実際、ReactのuseStateは内部でほぼuseReducerをそのまま呼んでいるだけで、useReducerも同じ理由で[state, dispatch]という配列を返しています。

ちなみに、Reactの全Hooksが配列を返すわけではありません。useContextは値を1つだけ、useRefはオブジェクト({current: ...})を返します。「呼ぶたびに毎回名前を変えたい、かつ関連する値がちょうど2つ」というときだけ、配列が向いている、というだけの話でした。


なぜJSXの属性はcamelCaseなのか

<div className="card" onClick={handleClick} tabIndex={0}>

HTMLならclass, onclick, tabindexのはずです。「classはJSの予約語だから」という説明をよく見ますが、これは半分だけ正確です。

半分正確な理由:予約語問題

たしかにclassはES6以降、クラス宣言のキーワードとして予約されています。ただし、モダンなJavaScriptでは予約語でもオブジェクトのプロパティ名としては普通に使えます。

const obj = { class: "hello" }; // これは文法エラーにならない

なので「予約語だから使えない」は、正確には「昔の名残」に近い説明です。

もっと本質的な理由:DOMプロパティにマッピングしている

React公式ドキュメントの説明はこうです。JSXの属性は、HTMLの「属性」ではなく、DOM要素が持つJavaScriptの「プロパティ」にマッピングされている。

const el = document.querySelector(".example");
el.className; // ← HTMLの属性文字列ではなく、DOM要素オブジェクトのプロパティ
el.tabIndex;

ブラウザのDOM APIは、複数単語のプロパティ名をcamelCaseで統一しています(addEventListener, scrollIntoViewと同じ流儀)。JSXの属性名は、HTMLタグの属性ではなくこのDOM APIの流儀に合わせているので、camelCaseになる、というのが本質的な理由です。classだけが特別扱いされているように見えるのは、たまたまclassというプロパティ名がJS予約語と衝突するので、DOM API側が最初からclassNameという別名を採用していたから、というだけの話でした。


なぜコンポーネントは単一のルート要素を返す必要があるのか

// これはエラーになる
function App() {
  return (
    <h1>タイトル</h1>
    <p>本文</p>
  );
}

「複数のJSX要素を並べて返せない」、これも初心者がまず躓くポイントです。理由は、実は第2回の話に直結しています。

createElementは1つのオブジェクトしか作れない

第2回で見た通り、JSXはReact.createElement(...)という関数呼び出しに変換されます。関数は1回の呼び出しにつき1つの値しか返せません。createElementが返すのも常に1個のオブジェクトです。<h1><p>を並べて書いても、それを1個のオブジェクトにまとめる方法がないので、素直に書くとJS的にエラーになります。

そしてこれは「木」の都合でもある

もう1つ、第2回でReconciliationの話をしたときの前提を思い出してください。Reactが差分を取っているのは木構造でした。親のchildren配列の中に、子が1個ずつ、決まった位置にぶら下がっている必要があります。あるコンポーネントの出力が「バラバラな複数の何か」だったら、親から見て「どの位置に何が入っているのか」が定義できず、diffのしようがありません。

親の子要素スロットに1個のコンポーネントの返り値がきっちり収まる例と、2個の要素を1つのスロットに収めようとして構文エラーになる例を比較した図

単一ルートという制約は、木構造として差分比較可能であるための最低条件でもあったわけです。

Fragmentという回答

とはいえ、<div>で無理やり囲むと、意味のないdivがDOMに増え続けます。そこでReact 16(2017年)で2段階の解決策が入りました。

  • React 16.0renderから配列を直接returnできるようになった(各要素にkeyが必要)
  • React 16.2Fragment<React.Fragment>、省略記法<>...</>)が追加され、DOMに余計なタグを増やさずに複数要素をまとめられるようになった
function App() {
  return (
    <>
      <h1>タイトル</h1>
      <p>本文</p>
    </>
  );
}

<>...</>は、「木構造としては1つのノードだけど、DOMには何も残さない」という特殊な要素です。「単一ルート」という制約自体は今もそのままで、Fragmentはその制約を守りながら、余計なDOM要素を増やさない抜け道を用意した、という位置づけになります。


なぜHooksは「use」で始まる関数呼び出しという形なのか

const [count, setCount] = useState(0);
useEffect(() => { /* ... */ }, []);

第3回で、useState呼ばれた順番=配列のインデックスで管理されていることを自作して確認しました。この「順番が命」という性質が、useという接頭辞の理由に直結しています。

「use」はReact本体には強制されていない

意外に思うかもしれませんが、useという接頭辞は、JavaScriptの文法にもReact本体のコードにも強制されていません。 試しにgetCountのような名前にしても、中身が同じなら普通に動きます。

function getCount(initialValue) { // "use"じゃなくても動くには動く
  // 中身はuseStateと同じ実装
}

じゃあ何のためにあるのか:ESLintが見分けるための目印

Reactには「Hooksはトップレベルでのみ呼ぶ、条件分岐やループの中で呼んではいけない」というルールがありました(第3回参照)。このルールを自動でチェックしてくれるESLintプラグインeslint-plugin-react-hooks)は、コードを実行せずに静的に見ているだけなので、「どの関数呼び出しがHooksなのか」を名前だけで判断するしかありません。その判断基準が、useで始まっているかどうかです。

// ESLintが検知できる
function Component({ isLoggedIn }) {
  if (isLoggedIn) {
    useUserProfile(); // ← "use"で始まってるから、条件分岐の中はダメだと分かる
  }
}

// ESLintが検知できない
function Component({ isLoggedIn }) {
  if (isLoggedIn) {
    getUserProfile(); // ← ただの関数呼び出しにしか見えない
  }
}

同じ違反でも、名前がuseで始まっていないだけで、ESLintは見逃します。useは、実行時のReactにとってはただの名前ですが、開発時のツール(ESLint)にとっては「このルールに従う関数です」という自己申告になっているわけです。第3回で自作したcursorの仕組みは、呼び出し順が変わると本当に壊れる、脆い前提の上に立っていました。だからこそ、人間にも機械にも一目で分かる目印が必要だった、ということでした。


まとめ

  • useStateが配列を返すのは、分割代入のたびに好きな名前をつけられるように。オブジェクトだと毎回リネームが必要になる
  • JSXがcamelCaseなのは、HTML属性ではなくDOM要素のJSプロパティにマッピングしているから。予約語問題はclassNameに限った話の一部でしかない
  • コンポーネントが単一ルートを返す制約は、createElementが1つの値しか返せないことと、木構造として差分比較可能であることの両方が理由。Fragmentはその制約を保ったまま余計なDOMを増やさない抜け道
  • Hooksがuseで始まるのは、ESLintが静的にルール違反を検知するための目印。React自体は強制しておらず、あくまで人間とツールのための約束事

文法のクセに見えていたものは、たいてい「制約への回答」でした。


参考文献

  • React公式ドキュメント「DOM Elements」
  • React Blog「React v16.2.0: Improved Support for Fragments」(2017)
  • eslint-plugin-react-hooks公式ドキュメント

次回

**Fiber ~ なぜReact 16でアーキテクチャが刷新されたのか ~**

  • 差分計算、実は「一気に」やると画面がカクつく問題があった
  • Reactが「作業を中断・再開できる」仕組みに作り替えた理由
  • 優先度をつけてレンダリングするってどういうこと?

を書きます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?