Reactちゃんと学ぶ
いい加減フロントエンド勉強しよう
一旦、Reactについてちゃんと知ることから
とりあえず、公式のチュートリアルとAPIリファレンス読む
チュートリアル
- 現時点でのReactのバージョンはv18.3.1
- Reactはライブラリ。データfetchやルーティングの術はない。RemixやNextのようなFWを使用すること
- サクッと管理画面などを作りたい時にVite + Reactのような構成で作りたくなるというのを聞いたことがあるし実際そんなイメージがある
- 公式ドキュメントにそう記載されているのだからReactを使いたい時は何かしらのFWで使用する
- Reactが言うコンポーネントとはマークアップを返すJS関数。
- useStateのようなuseで始まる関数はHookと呼ばれる
- 通常stateは使用したコンポーネントの中に閉じられる
- 他のコンポーネントとstateを共有したい場合は親のコンポーネントにstateを移し、propsで共有したいコンポーネントに渡すことで共有できる。これをstateのリストアップという
Reactの流儀
コンポーネント分割
Reactでまずやることはデザインをコンポーネントに分割すること。これはプログラマーやデザイナーでどのように分割するかの思考が異なるかもしれないが普段やっていることと同じだと書かれている。
サーバー目線で言うと単一責任の原則に従ってコンポーネント分割すると良いかもしれない。つまり、一つのコンポーネントの役割は一つだけということ。もしコンポーネントが大きくなってしまったらサブコンポーネントに分割すればよい。以下は公式ドキュメント記載記載のコンポーネント分割の例の図。
静的なサイトを構築するだけならpropsでデータをコンポーネントに渡していくことでコンポーネントを組み立てることができる。コンポーネントをトップダウンでもボトムアップでも構築することは可能だが、通常大規模なプロジェクトであればボトムアップの方が楽。ちなみに、静的サイトであればstateは一切必要ない。stateはインタラクティブなサイトを構築するために必要であり、ユーザーからの入力によって変化する画面の構築に必要なもの。
単一データフロー(one-way data flow)
Reactは親コンポーネントから子コンポーネントへ一方向にデータを渡し、逆方向にデータを渡すことはできません。この原則によりデータの管理や追跡が容易になっている。子コンポーネントから親コンポーネントの状態を変化させたい場合にはデータの受け渡しではなく親コンポーネントから受け取ったコールバック関数を実行することで実現することになる。
stateの考え方
stateの設計で重要なことはDRY原則。つまり、アプリケーションが必要とする必要最小限のものをstateとして設計し、それ以外の計算可能なものは必要となったときに計算する。stateとして設計すべきものは例えば以下のようなもの。
- 時間の変化で変わる値
- 親コンポーネントからpropsで渡されないもの
- 親コンポーネントから渡ってきたpropsや既存のstateから計算できないもの
JSX
Reactでコンポーネントを作成するためのJavaScriptの拡張構文。JSXはマークアップを返す関数を定義しそれをexportして使う。JSXには以下のような特徴がある。
- まるでHTMLを扱っているようだが、それはJavaScriptだそう
- 複数行になる場合は()で括る必要がある。
- 同じ階層のタグを複数返したい場合はdivや空タグで囲って返す必要がある
- これはJSXがJavaScriptだからで単一の値を返す関数が複数の値を返せないのと同じ
- JSXではHTMLよりも厳格なため、例えば全てのタグは閉じなければならない
- HTMLの属性はキャメルケースで書く
- これはJSXがJavaScriptだからで例えば、JSXでclass属性を指定するときにclassNameと書くのはJavaScriptでclassが予約語だから
HTMLをJSXに変換するコンバータも存在するらしいので覚えておくといいことあるかも。
コンポーネントを作る != 共通化
ちょっと前にXでtailwindの話になったときReactのコンポーネントを作成することを共通化と呼んでいる人がけっこういた気がする。
ほとんどの React アプリでは隅から隅までコンポーネントが使われます。つまり、ボタンのような再利用可能なところでのみ使うのではなく、サイドバーやリスト、最終的にはページ本体といった大きなパーツのためにも使うのです。コンポーネントは、1 回しか使わないような UI コードやマークアップであっても、それらを整理するための有用な手段です。
上記の引用はReactのドキュメントからの引用。
勝手な想像だけどやっぱりサーバー目線でフロント側に口を出すとどうしてもサーバー側の設計論がベースになってしまうから、共通化と言ってしまうのだろうがそもそもサーバーとフロントでは土俵が違いすぎるので一旦サーバーの話は置いておいたほうがいいと思う。
少なくともドキュメントにはコンポーネントは隅から隅まで作成すべきで1度しか使われないようなものもコンポーネント化すると記載されている。コンポーネントを作るということは再利用可能な部品を作るという面も確かにあるのだろうが、それが主目的ではなくモックアップをコンポーネント分割し、組み立てるというフロントエンドの開発フローで必要な過程なのだと勝手に理解した。
UIの記述
- Reactではimportするファイルの拡張子を省略できる。
- ファイル拡張子は指定してもいいし省略もできるが、省略したほうがネイティブESモジュールの動作に近い
- ネイティブESモジュールとはwebブラウザ標準で対応しているモジュール機能で、以下のようなimportmapを定義することで好きな名前でモジュールインポートができる。
<script type="importmap">
{
"imports": {
"shapes": "./shapes/square.js",
"shapes/square": "./modules/shapes/square.js",
"https://example.com/shapes/": "/shapes/square/",
"https://example.com/shapes/square.js": "./shapes/square.js",
"../shapes/square": "./shapes/square.js"
}
}
</script>
- propsはimmutable(不変)
- しかし、常に値が固定なわけではない
- 親コンポーネントから渡ってくる値は変化する可能性はある
- JSXでlistやmapをループで扱う場合、key属性を指定する必要が必ずある
- これは要素のソート、追加、削除時にReactがDOMを効率的に生成するために必要
- keyにlistのインデックスを指定してはいけない
- インデックスは要素の変化によって変わりうる
- 動的に生成された値も同様
- keyには変わらない兄弟間で一意な値を指定する必要がある
Reactと純粋関数
Reactにおける関数は純粋関数が望ましいとされており、同じ入力であれば毎回同じコンポーネントをレンダリングすることが期待されています。
コンポーネントはpropsとstate、contextという3つの値を読み取ってレンダリングしますが、これらの値は読み取りのみで書き換えようとしてはいけない。
ユーザー入力に応じて何かを変更したい場合はstateを使うことを検討するのが良い。
Reactでは<React.StrictMode>
というものがあり、コンポーネントを2回レンダリングする。
2回レンダリングすることでコンポーネントの冪等性を見ている。Strictモードは開発環境のみで本番環境のパフォーマンスに影響を与えることはない
とはいえプログラムを書く以上副作用は発生する。Reactではイベントハンドラは副作用。
しかし、イベントハンドラはレンダリング後に実行されるためイベントハンドラは副作用があっても良い。
その他、**useEffect()**も副作用を書くことができるがこれは最終手段で極力書くべきではない。
極力副作用を描きたくなったらイベントハンドラで書けないかを考えるべき。
アプリケーションを作る上でデータフェッチが最も書きたくなる副作用だと思うがこの副作用をどう扱うのかがまだよくわかってない。
昔はuseEffectに書いてしまっていたがこれはなるべく避けたほうが良いのだろう。
stateはスナップショット
レンダー内のstateは決して変わることはなく固定されている。
以下はドキュメントの例。
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
alert(number);
}}>+5</button>
</>
)
}
上記のクリックイベントが実行されるとstateを更新した後にアラートを出しているので更新された後の5
という値がアラートに表示されることを期待するかもしれませんが、これは0
が表示される。
stateの更新で再レンダーが走るがそのレンダー内のstateは変わることはないので初期stateの値が使用されてアラート表示される。
stateの更新関数はキューで処理される
stateの更新関数はキューに入れられ、イベントハンドラ関数が終了した後、レンダーがトリガーされキューの中が処理される。
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);
}}>+3</button>
</>
)
}
上記の例はstateの更新関数に値ではなく更新関数を指定してイベントハンドラ内でstateを更新する例。
前回の例ではstateは固定されると説明したため想定通りの挙動にはならなかったが、更新関数を指定することで固定されていない値を使用することができる。
上記の例でいうと以下のように処理される。
- クリックによるイベントハンドラが実行される
- stateの更新関数3つがキューにためられる
- イベントハンドラ終了をトリガーに再レンダーがはしる
- レンダー中にキューの更新関数が実行される
- 更新関数の引数の
n
には更新関数の戻り値が使用されるため最終的な結果は3
になる
例えば
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setNumber(n => n + 1);
setNumber(42);
}}>Increase the number</button>
</>
)
}
上記のような場合、stateの更新関数が3つキューに貯まるが、最終的に42で更新するため最終的なstateの値は42としてレンダーされる。
- stateにobjectや配列といった参照型の場合、immutableに保つことが重要。
- stateのフィールドを書き換えてstateの更新に指定することもできるがこれはstateのスナップショットの特性を破壊することになる
- Reactは前回のstateと更新後のstateの参照を比較して参照が変わっていないためレンダーが走らないといった予期せぬバグに遭遇したり、デバッグを困難にしたり、そもそもReactの新機能の開発はstateの値が変わっていないことを前提としているため新機能に追従できなくなったりと多くの不都合を生む。
- そのため、objectや配列のstaeは必ずコピーして更新することが重要。
- mutationを行わないのは配列も一緒
- 基本、スプレッド構文やslice()を使い、push()やpop()といった元の配列を変更してしまうような処理はすべきでない
- map()やfilter()はコピーしてから新しい配列を返すので安全
- バグを起こしやすいのはオブジェクトの配列で配列内の一部のオブジェクトの値を変える場合
- この場合、更新するオブジェクトの変更もスプレッド構文やslice()を使うことで安全に変更できる
stateの管理
親コンポーネントが再レンダーされると基本的にはその子コンポーネントも再レンダーされる。しかし、ReactはDOMツリーを作り、どのコンポーネントを再構築するか、どのコンポーネントは再構築せずそのままにするかを決める。
なので、再レンダーさせたいときに再レンダーされないということも起こりうる。そういった時のテクニックとしてkeyに親コンポーネントなどのstateの値などを渡すことでsatateが変わった時に必ず再レンダーがかかるようにするなどがある。
イベントハンドラが多くあり、sateの更新が散らばる場合はreducerを使うことでstateの更新処理を一箇所に集めることができる。詳しい実装はAPIリファレンスやるときに。reducerを使うことでイベントハンドラはアクションを発行するだけでよくなる。
reducerの使用について
Reactにおいてreducerを使うかuseStateを使うかは好みの問題です。どちらを使っても問題ない。
一般的にstateの更新が各イベントハンドラに散らばってわかるりづらい場合はreducerを使って集約した方が可読性が上がるかもしれない。しかし、reducerを使う方がコード量が多くなることが多い。
reducerの書き方としてreducerはレンダー中に実行されるため純粋関数である必要がある。そのためコンポーネントの外部に影響を与えるような操作やstateを書き換えるような操作はしてはいけない。
reducerもImmerを使うこともできる。
contextとreducerの併用
useContextを使うことで深い階層のコンポーネントに値を渡すことができる。useContextは便利なので使いすぎには注意が必要。
- まずはpropsで渡すことができないかを検討する
- コンポーネントをpropsで受け取るようにすることでpropsのバケツリレーをしなくてよくなるかもしれない
これらで解決できなそうであればuseContextを検討するくらいで良い。
useReducerで作成したstateとdispatch関数をそのコンポーネントより下の階層全てで使いたいような場合、useContextを使いstateとdispatch関数をcontextに入れることができる。
contextをexportしているファイルで新たにコンポーネントを作成し、
reducerの作成とカスタムhookの作成、propsでコンポーネントを受け取るようにするとスッキリする。
import { createContext, useContext, useReducer } from 'react';
const TasksContext = createContext(null);
const TasksDispatchContext = createContext(null);
export function TasksProvider({ children }) {
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);
}
function tasksReducer(tasks, action) {
switch (action.type) {
case 'added': {
return [...tasks, {
id: action.id,
text: action.text,
done: false
}];
}
case 'changed': {
return tasks.map(t => {
if (t.id === action.task.id) {
return action.task;
} else {
return t;
}
});
}
case 'deleted': {
return tasks.filter(t => t.id !== action.id);
}
default: {
throw Error('Unknown action: ' + action.type);
}
}
}
const initialTasks = [
{ id: 0, text: 'Philosopher’s Path', done: true },
{ id: 1, text: 'Visit the temple', done: false },
{ id: 2, text: 'Drink matcha', done: false }
];
避難ハッチ
- useRefでrefを作成できる
- refの現在の値には
ref.current
でアクセス - refの値はイミュータブル。つまり読み書きが可能
- refはstateと違いReactの管理外
- そのためrefは値を更新しても再レンダリングが走らない
- refのよくあるユースケースはDOM操作をするケース
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
- 参照したい要素のref属性を指定する
- 上記の例ではinputタグの参照をrefに持っているためボタンクリックでinput要素のフォーカスが当たる
- これをもしstateでやると再レンダリングが走ってしまうやりたいことができない
- refはカスタムコンポーネントには指定できない
- これはただでさえ控えめに使うべきrefが多用されないようにするための意図的な仕様
- もし、他のコンポーネントのDOMを操作したい場合、forwardRef が使える
- forwardRefはforwardRefでコンポーネントをラップし、propsに加え、第二引数にrefを取れるようにすることでコンポーネント内の要素にrefを埋め込むことができるようになる
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
- 通常、refにアクセスするのはイベントハンドラ。
- 適切なイベントハンドラがない場合はuseEffectが必要かもしれない
- これはDOM操作が副作用であり、副作用を扱って良いとされているのはイベントハンドラとuseEffectだから
APIリファレンス
フック
- useState
- useReducer
- useContext
- useRef
- useCallback
- useMemo
他にもあるけど基本的にこれだけ使えれば問題なさそう。useCallbackとuseMemoはパフォーマンス向上のためのフックで基本的には使わなくても問題ない。非常に重たい処理をする時や関数をpropsとして渡す場合なんかは計算コストを削減できたり毎回propsが違うものと見なされ再レンダーがかかってしまったりみたいな問題が回避できるので有効。そもそも初回レンダーのパフォーマンスは向上しなかったりなどもあるのでよく考えて、有効であると判断できたなら使うくらいで良さそう。
全部メモ化するなんて話も聞くけどそれは無意味なメモ化である可能性は高い。少なくともメモ化は可読性を損ねる。
フラグメント
Reactは以下のような組み込みのコンポーネントがある。
- <Fragment> 別の書き方として<>...</>
- <Profiler> Reactツリーのレンダーパフォーマンスを測定できる
- <Suspense> 子コンポーネントがロードされる間、フォールバックを表示することができる
- ちょっとよくわからないので後でもう少し掘り下げる
- <StrictMode> 2回レンダーしてバグ見つける開発モード
Suspense
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
上記のようにして使う。基本的には子コンポーネントがデータフェッチなどでレンダリングできる準備が整うまでfallbackに指定のコンポーネントを表示してくれるというもの。いわゆるローディング表示でスピナーとか表示する。
Suspenseは子コンポーネントのイベントハンドラやuseEffect内でのデータフェッチを検知しない。今のところは。Suspenseは低レベルのコンポーネントで実際はReactQueryのようなライブラリを使用することになるが内部ではSuspenseの仕組みが使われている。
Suspenseが低レベルのコンポーネントでuseEffect内でのデータフェッチを検知しないというのは、実際にはSuspenseの子コンポーネントのデータフェッチがPromiseをthrowするというのが重要らしく、SuspenseはthrowされたPromiseを検知し、fallbackのコンポーネントをレンダーする。そして、Promiseが解決されると再レンダーがかかり、すでにデータを取得済みのため子コンポーネントはPromiseをthrowしないため、fallbackではなくちゃんと子コンポーネントがレンダーされるという仕組みらしい。詳しくは以下のuhyoさんのzenn本が本当にわかりやすく説明されている。
API
createContextやforwardRef、memoなどがReactパッケージには用意されている。他にもあるが使いそうなのがここら辺だったのでとりあえず。特にmemoはコンポーネントをmemo化するのに使うので1番使うかもしれないが、大抵の場合はmemo化する必要はないと公式ドキュメントには記載がある。useMemoやuseCallbackと同様、そのコンポーネントが頻繁に再レンダーされ、そのレンダーが高コストな場合にmemo化は有効。そうでなければ大抵memo化はそこまで効果を発揮しないということだと理解。
react-dom
- react-dom
- react-dom/client
- react-dom/server
DOMに関わるAPIとか。必要になったときにまた調べる。
RSC(React Server Component)
RSCはサーバーコンポーネントという新たなReactの概念であり、クライアントアプリケーションやSSRサーバーとは別の環境でバンドル前にレンダーできるコンポーネントのことである。
RSCはReact18系において実験的機能の扱いであるが、Nextのようなフレームワークではすでに使われている機能となる。
RSCには以下のような登場人物がいる。
- クライアントコンポーネント
- サーバーコンポーネント
- サーバーアクション
クライアントコンポーネント
'use client'をトップレベルに記述することでそのファイル内の関数はクライアントで実行される。後述するがデフォルトではすべてサーバー側で実行されるサーバーコンポーネントであり、'use client'を記載することでクライアントコンポーネントとして扱うことができる。
'use client'の記載はクライアントコンポーネントの絶対条件ではなく、'use client'記載のクライアントコンポーネントの関節的な依存コンポーネントもクライアントコンポーネントとなる。
そのため、同じコンポーネントでもサーバーコンポーネントにもクライアントコンポーネントにもなりうるということ。
ちなみに、依存コンポーネントというのは子コンポーネントということではなく、moduleの依存ツリーで考えた時の依存関係であるため、クライアントコンポーネントの子コンポーネントにサーバーコンポーネントがあるみたいなのも違和感があるかもしれないが全然ありうる。
サーバーコンポーネント
前述したようにRSCにおいて、デフォルトではすべてサーバーで実行されるサーバーコンポーネント。この利点はコンポーネントをレンダリングするためにデータフェッチしたり、ファイル操作をビルドサイズを増やしてやるみたいなのを全部ビルド時にサーバー側で済ますことができ、マークダウンを返すことができ、CDNでキャッシュすることもできるといういいことづくめの機能。
まだよくわからないのが例えばidをパラメーターとしてDBからデータを取得してコンポーネントをレンダリングするようなサーバーコンポーネントがあったとき、データの更新があったときにリアルタイムに反映ができないような気がするのだけど、再フェッチと再レンダーみたいなのはどういう仕組みになってるの??
サーバーアクション
'use server'を記述したサーバーコンポーネント内の非同期関数はクライアントからでも呼べるサーバーアクションとなる。
'use server'がサーバーコンポーネントではないので注意。
サーバーアクションはサーバーコンポーネント内でのみ定義できる。
サーバーアクションは従来のSSRとはまた違くて、SSRはページアクセスの際にページを丸ごとサーバーでレンダリングしてhtmlを返すがサーバーアクションは関数の実行である。
一般的なユースケースとしてデータの更新がドキュメントに記載されているがクライアントコンポーネント内から簡単にサーバーリソースを更新するような関数を呼び出せるというのがformの書き方を大きく変えるよねということらしい。
async function requestUsername(formData) {
'use server';
const username = formData.get('username');
// ...
}
export default function App() {
return (
<form action={requestUsername}>
<input type="text" name="username" />
<button type="submit">Request</button>
</form>
);
}
これが発表されたときにSQLクエリをReactから発行するコードがジョーク的に広まっていたが、サーバーで実行できる関数処理ならなんでもReactから呼べるようになったぜという凄さをわかりやすくコードにしたのが広まった感じだろう。
確かに、このRSCを全面採用したReactの書き方は今までの書き方を大きく変えうるという感覚は持てた。実際にRSCを実践的に使えるNextの方をまだ見てないのでよくわからないがデータフェッチ系はサーバーコンポーネントが、更新系はサーバーアクションを使うように変わるのだろう。
サーバーコンポーネントのデータの再フェッチみたいなところはまだよくわかってないけど。
サーバーコンポーネントはリクエストの度にサーバーで実行される
ビルド時にコンポーネントをレンダリングして使い、定期的に再フェッチ、再レンダリングするみたいなのはSSGやSSR, ISRなどの考え方であり、RSCとはまた異なる。
少なくともRSCにおけるサーバーコンポーネントはマウントされるたびにサーバーでレンダリングされるため、常にリアルタイムな情報でコンポーネントをレンダリングできるらしい。
ちょっとまだ完全に理解しきれていないが従来のSSRやSSG, ISRといった技術の完全な置き換えがRSCというわけではないようだが、RSCを全面的に取り入れているNextなんかを使うならもはやSSRというかgetStaticPropsみたいなやつは使わなくなるのかね?
わからないけどこれ以上はNextの回にまた考えるとする。