この記事について
ここ最近React + MUIでフロントエンド開発をしており、自分の中でベストプラクティス的なものができつつあるので一旦まとめようと思い書いた記事
一部調査中につきTODOの状態(調査完了したら追記する予定)
React関連
コンポーネント
関数コンポーネントを使おう
Reactのコンポーネントは関数コンポーネント
とクラスコンポーネント
の2つに大別される
※公式ドキュメントから引用
// 関数コンポーネント👍
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
// クラスコンポーネント
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
どちらも全く等価なのでどう書くかは好みの問題になるが、個人的には記述量の少ない関数コンポーネントの方が良いと思う
また、関数コンポーネントはさらに以下の2通りの記法があり
import React from "react";
type Props = {
name: string;
}
// 関数式👍
function Welcome(props: Props) {
return <h1>Hello, {props.name}</h1>;
}
// アロー関数
const Welcome: React.FC<Props> = (props: Props) => {
return <h1>Hello, {props.name}</h1>;
};
TypeScriptで型定義をする場合はより記述量の少ない関数式(function)
の方が望ましいと思う
※ぶっちゃけ関数コンポーネントは戻り値がJSX.Element
であればいいのでアロー関数でわざわざReact.FC
で型付けする必要もそんなにないし、その場合記述量の差はほぼないので好みで決めていいと思う
AtomicDesignに従って階層を分けよう
ReactはUIの部品をコンポーネント
という単位で宣言して再利用可能にするのがウリ(と自分は思っている)なので、それを活かるようなディレクトリ構成にしましょうという話
AtomicDesignそのものについてはわかりやすい記事がたくさんあるので適当に見繕って読んでいただくとして、以下ではAtomicDesignをReactでのコンポーネント開発のケースにあてはめて解説する
Atoms
AtomicDesignの最小単位
AtomicDeisgnの原典では以下のように定義されている
These atoms include basic HTML elements like form labels, inputs, buttons, and others that can’t be broken down any further without ceasing to be functional.
つまり、<input>
や<button>
などのHTMLタグをそのままAtomsのコンポーネントとして実装すればOK
また、そうでなくても(複数のタグを組み合わせて実装しても)それ以上分解できないほどそのコンポーネントの役割が抽象的なのであればAtomsコンポーネントとして実装することになる
なお、Atomsの実装で重要なのはwidthやpaddingなどを決め打ちしないことと、それらを外部から注入できる形にする(=propsで受け取れるようにする)こと
Atomsのコンポーネントはあちこちで使いまわすことになるので、サイズを決め打ちしてしまうとSmallButton
とかNormalButton
みたいなサイズだけ違うボタンが乱立したりCSSで強引にサイズを変更したりとカオスなコードになってしまう
一方でなるべくデザインに統一感を持たせるためdefaultValueとしてwidthやpaddingを設定するのは大変有効で、他にもsmall=200px
, normal=300px
...のようなenum的な値を作っておくのも良いと思う
Molecules
Atomsを組み合わせて実装するコンポーネント
やや大きめの粒度のコンポーネントを実装する際それがMoleculesなのかOrganismsなのかよく悩んでいたが、自分は以下のようなフローチャートにのっとって決定するようにしている
単体のHTMLタグそのものか、そのコンポーネントの役割が極めて抽象的 ─YES→ Atoms
│
NO
↓
そのコンポーネントの役割がビジネスロジックと結びつくもの、具体的なリソースと直接関わるものである ─YES→ Organisms
│
NO
↓
Molecules
具体例を挙げると(Atomsは自明なのでスキップ)以下の通り
- Molecules
- 検索フォーム
- 役割は
検索したい文字列を入力し、検索機能を発火すること
であって、具体的に何を検索するかは定められていない(ビジネスロジックと結びついていない)のでMolecules
- 役割は
- 設定ボタン
- こういうやつ→⚙️
- これも何らかの設定画面ないしダイアログを開く役割を持つものの、具体的に何の設定かは定められていないのでMolecules
- 検索フォーム
- Organisms
- ユーザー検索フォーム
- 役割が
ユーザーの一覧取得APIをリクエスト
することで、ビジネスロジックと結びついているのでOrganisms
- 役割が
- ユーザー設定ボタン
- 役割が
ユーザーの設定変更画面を開くこと
なのでOrganisms
- 役割が
- ユーザー検索フォーム
Organisms
AtomsやMoleculesを組み合わせて実装する
Organisms内の各コンポーネントのスタイリングはしてもいいがOrganisms自体のサイズはここで決め打ちしてはいけない
Templates
Pagesから呼び出されるコンポーネントで、渡されたコンポーネント(基本Organismsのみ)をどのように配置するかについてのみ責務を負う
→スタイリングはするけどロジックはここでは持たない
Pages
TemplatesへOrganismsを渡す
また、自身の子コンポーネント(=Organisms)へ渡すべきstatesや関数などはここで宣言する
各コンポーネントの依存関係と役割のまとめ
- Pages
- Organismsを呼び出しTemplatesに渡す
- 基本的にMolecules、Atomsには関心を持たない
- Templates
- Pagesから受け取ったコンポーネント(=Organisms)を表示するだけ
- 各コンポーネントの大きさや配置などはここで決定する
- Organisms
- Atoms、Molecules、必要なら他のOrganismsを呼び出して1つのコンポーネントを作る
- 基本的に責務は一つでドメインと結びつくもの
- チャットを送信するフォーム、商品一覧のページング、プロフィール画面へ遷移するボタンなど
- Molecules
- Atomsを組み合わせて実装する
- Organismsと異なりドメインとは直接結びつかない抽象的なコンポーネント
- テキストボックスを複数持つ何らかの入力フォーム、ページ番号のstateを持つ汎用的なページングなど
- Atoms
- HTMLのタグと1対1で対応するコンポーネント
カスタムフックの実装
カスタムフックでviewとロジックを分離しよう
例えば以下のようなログインフォームがあった場合
import React from "react";
export function LoginForm() {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
return (
<form
onSubmit={(event) => {
event.preventDefault();
AuthAPI.login(email, password)
}}
>
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
<button type="submit">LOGIN</button>
</form>
);
}
最低限のロジックしかなくCSSすら書いていないのに結構煩雑で読むのが面倒くさい
また、UTを書く際にもviewとロジックを1ファイルにまとめて書くことになるのでメンテナンスが大変
なので、以下のようにあるコンポーネントで使用したい変数、関数をまとめて返すようなカスタムフックを実装してviewとロジックを分離しよう
import React from "react";
import { LoginFormProps } from "./LoginForm.tsx";
export function useLoginForm(): LoginFormProps {
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const onChangeEmail = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) =>
setEmail(event.target.value),
[]
);
const onChangePassword = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) =>
setPassword(event.target.value),
[]
);
const onSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
AuthAPI.login(email, password);
},
[email, password]
);
return {
email,
onChangeEmail,
password,
onChangePassword,
onSubmit,
};
}
import React from "react";
export type LoginFormProps = {
email: string;
onChangeEmail: (event: React.ChangeEvent<HTMLInputElement>) => void;
password: string;
onChangePassword: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
};
export function LoginForm(props: LoginFormProps) {
return (
<form onSubmit={(event) => props.onSubmit(event)}>
<input
type="email"
value={props.email}
onChange={(event) => props.onChangeEmail(event)}
/>
<input
type="password"
value={props.password}
onChange={(event) => props.onChangePassword(event)}
/>
<button type="submit">LOGIN</button>
</form>
);
}
色んなページから参照したい値はContextAPIで管理しよう
例えばAPI実行中に二重でサブミットするのを防ぐためボタンをdisabledにするためのフラグや、認証したユーザーのstateなど
このような値は以下のようにContextAPIを使って管理しよう
import React from "react";
type LoadingContext = {
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
};
export const loadingContext = React.createContext<LoadingContext>({
setLoading: () => null,
});
export function LoadingProvider(props: React.PropsWithChildren<{}>) {
const [loading, setLoading] = React.useState(false);
return (
<loadingContext.Provider
value={{
setLoading,
}}
>
{props.children}
</loadingContext.Provider>
);
}
コンポーネント毎のカスタムフックの役割のまとめ
- Providers
- 各Pagesで使用したい変数/関数はここで生成する
- Pages
- このページ内の各コンポーネントで使用したい変数/関数はここで生成する
- 商品一覧ページなら商品の配列などが該当する
-
ItemTable.tsx
なら配列そのものを使用するし、ItemPagination.tsx
なら配列の長さによってページ番号を動的に決定することになる
- このページ内の各コンポーネントで使用したい変数/関数はここで生成する
- Organisms
- そのコンポーネントで必要な変数や関数を生成する
- 商品一覧テーブルならクリックした商品の詳細ページへ遷移させるとか
- Molecules
- Organismsと同様
- Atoms
- Organismsと同様
Material-UI関連
非デザイナーの自分はCSSのアセットとしてMaterial-UI(MUI)をよく使用しているので、それによって得た知見をまとめる
デザイン
ThemeProviderを活用しよう
defaultProps
MUIのボタンは以下の3種類の見た目が予め用意されている
これらを切り替えたい場合はvariant
propsを変えてやればOK
<Button variant="text">Text</Button>
<Button variant="contained">Contained</Button>
<Button variant="outlined">Outlined</Button>
ただし、このアプリではcontained
で統一したい、このアプリは基本outlined
を使いたいといった場合に、毎回variant="contained"
と書くのは面倒だしチーム開発の場合は違うvariantを指定してしまう可能性もある
この問題を解決するためにはMUIのButtonを継承しvariantのデフォルト値を設定したAtomsコンポーネントを実装する手段もあるが、より手っ取り早い方法としてThemeProviderのdefaultPropsを活用するのがおすすめ
import React from "react";
import { createTheme, ThemeProvider as MuiThemeProvider } from "@mui/material";
const theme = createTheme({
components: {
MuiButton: {
defaultProps: {
variant: "contained",
},
},
},
});
export function ThemeProvider(props: React.PropsWithChildren<{}>) {
return <MuiThemeProvider theme={theme}>{props.children}</MuiThemeProvider>;
}
このようなプロバイダーを実装し、アプリのルートコンポーネント(create-react-app
で作った場合はindex.tsx
)で呼び出すことでpropsのデフォルト値を変更できる
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ThemeProvider } from "./providers/ThemeProvider";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
root.render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</React.StrictMode>
);
palette
ダークモード対応
独自定義のvariant
TODO
スタイリング
各コンポーネントのsize
やcolor
などのpropsでは対応できないくらいのスタイリングをしたい場合は以下の2つのAPIを使用する
styled関数
styled-componentsをMUI向けにラップした関数
import * as React from 'react';
import Slider, { SliderProps } from '@mui/material/Slider';
import { alpha, styled } from '@mui/material/styles';
const SuccessSlider = styled(Slider)<SliderProps>(({ theme }) => ({
width: 300,
color: theme.palette.success.main,
'& .MuiSlider-thumb': {
'&:hover, &.Mui-focusVisible': {
boxShadow: `0px 0px 0px 8px ${alpha(theme.palette.success.main, 0.16)}`,
},
'&.Mui-active': {
boxShadow: `0px 0px 0px 14px ${alpha(theme.palette.success.main, 0.16)}`,
},
},
}));
export default function StyledCustomization() {
return <SuccessSlider defaultValue={30} />;
}
基本的な使い方はstyled-componentsと同様に、カスタマイズしたいコンポーネントにCSSを書き込んでラップする形になる
@mui/material/styles
のstyled関数
の特徴として、MUIのThemeオブジェクトを参照できるのでThemeProvider
でカスタマイズした値を利用することができ、エディタのコード補完の恩恵を受けられる
CSSをラップしたコンポーネント内に閉じて再利用することが可能なので、いろいろな場面で使いまわしたい場合は↓のsxよりこちらのstyled関数の方が良い
sx props
各コンポーネントのsx
propsにCSSを渡す方法
<Slider
defaultValue={30}
sx={{
width: 300,
color: 'success.main',
}}
/>
styled関数よりも手軽に書けるもののパフォーマンス的には劣る
なのでわざわざラップして再利用しやすくするまでもないCSSを書きたい場合に使用するのが良い