Reactにおける再利用とテストを容易にする疎結合なUIを目指す3つのTips
はじめに
コード上での問題を正確に認識しておかなければ、問題を繰り返すのです。Reactを使用したプロジェクトに参画したり、OSSプロジェクトのソースコードを散見すると複雑な仕様に立ち向かったUIに出会うことがあるでしょう。
複雑な仕様に立ち向かったUIは以下の特徴があると考えています。
-
bundle size
が肥大している - 保守や維持の管理が高い
- 他開発者にこのUIは何をやっているのか、質問をしなければならない。
- 質問の回答を聞いてもそのUIが実行していることが多様で理解しづらい。
- 再利用性が低い
- そのUIを利用するために満たさなければならない条件が多く、新しく似ているUIを実装することになる。
- 複雑なAPI
- 片手の指の数では溢れる props の数が存在している
- ユースケースを満たすために、既存の機能を使えば実装ができるのか、判断がしづらい
上記のようなUIを見かけた場合、どのような観点でリファクタリングするのか、このようなUIを避けるには何をすればよいか、再利用とテストを容易にする疎結合なUIに目指すためにTipsを解説していきます。
HTML と CSS の責務は単一だろうか
<Button
prefix={helpIcon}
surfix={heartIcon}
hover={filledHelpicon}
text="hello"
color="green"
primary={true}
toolip={<Tooltip/>}
onClick={handleClick}
...
/>
これはオブジェクト指向プログラミング(OOP)における継承に近い実装になります。
上記はButtonコンポーネント
を描画する役割を持ちながら、Iconコンポーネント
やTooltipコンポーネント
を描画する役割を継承しているのです。
なので、継承よりも合成を意識して実装しなければなりません。props
ではなくchildren
を持たせましょう。
<Button
primary
onClick={handleClick}
>
<HelpIcon/>
<InnerText color="green">hello</InnerText />
<HeartIcon />
</Button>
またJavaScriptでの振る舞いは関心の分離/責務の分離はできているが、HTML や CSS の責務の役割は分割されておらず、見た目、構造、インタラクションや余白管理を一つのセレクタ
、cssによる深いネストによって実装されているUIを見かけます。
複雑な仕様に立ち向かうには HTML、CSS にも単一責任を原則にそった実装をしなければなりません。単一責任を意識すると冗長で不要なCSSも削減されるでしょう。
参考文献
新規機能を追加する前に考えること
既存の関数に条件分岐、引数、戻り値を追加する前に State Reducerパターン を検討しよう
聞き馴染みのないデザインパターンかもしれません。
リファクタリングの著者として有名なMartin Fowler氏が広めたとされている Inversion of Controlパターン を踏襲したデザインパターンになります。 Inversion of Controlパターン は 制御の反転とも呼ばれていたり、Dependency Injectionパターン、依存性の注入 とも呼ばれています。このデザインパターンは広く認知されていて、他言語でも頻繁に使用されているでしょう。
State Reducerパターン はReact Testing Libraryの作者でもあるKent C.Dodds氏が発案しました。これを発案したキッカケは非常に学びがあります。
もしも、あなたがある関数に引数、オプションや条件分岐を追加すると実装ができる新規ユースケースの話がありました。 そのユースケースを実装するために、あなたの再利用可能なコードに追加する機能に関連する引数として props や条件分岐を追加するのです。
これを1回だけなら、いいかもしれません。ですが、複数回に渡って他開発者も引数、オブションや条件分岐を追加するとどうなるのでしょう この記事の最初にある複雑な仕様に立ち向かったUIになっていくのです。
それを回避するのが、State Reducer パターン になるのです。
具体例のユースケースは以下の通りです。
私たちはあるページのカウントするボタンに機能追加しなければなりません。
既存のカウントするボタンは
- プラスボタンをクリックした時に1ずつ増え、最大5まで増える処理
- マイナスボタンをクリックした時に1ずつ減り、最小0まで減らせる処理
追加したい機能は
- プラスボタンをクリックした時に1ずつ増え、最大10まで増える処理
- マイナスボタンをクリックした時に2ずつ減り、最小0まで減らせる処理
よくある場合は useCounter()
に引数や条件分岐を追加して、クリック可能な上限回数と各クリックごとの減少を定義するでしょう。
const useCounter = () => {
const [state, dispatch] = useReducer(reducer, initialState);
function reducer(state, action) {
switch (action.type) {
case "increment":
return {
count: Math.min(state.count + 1, 5)
};
case "decrement":
return {
count: Math.max(0, state.count - 1)
};
// 条件分岐に新規ユースケースを追加する
case "decrement-two":
return {
count: Math.max(0, state.count - 2)
};
default:
throw new Error();
}
}
const handleIncrementClick = () => {
dispatch({ type: "increment" });
};
const handleDecrementClick = () => {
dispatch({ type: "decrement" });
};
// 新規ユースケースために実行する関数を追加する
const handleDecrementTwoClick = () => {
dispatch({ type: "decrement-two" });
};
return {
count,
handleIncrementClick,
handleDecrementClick,
handleDecrementTwoClick, // UIコンポーネントで使用できるように戻り値を追加
}
}
ですが、State Reducerパターン はuseCounter()
にロジックを追加するのではなく、Usageコンポーネント
(useCounter()
を使用する下位コンポーネント)から新規ユースケースのreducer
(ロジック)を定義して、useCounter()
に引数として渡しています。
これをすることで、既存の機能やuseCounter()
に影響を与えずに機能を追加することができるのです。
const Usage = (): JSX.Element => {
const reducer = (state: typeof initialState, action: ACTIONTYPE) => {
switch (action.type) {
case "decrement":
return {
count: Math.max(0, state.count - 2) // マイナスボタンをクリックした時に2ずつ引かれる処理 (default:マイナスボタンをクリックした時に1ずつ引かれる処理)
};
default:
return useCounter.reducer(state, action);
}
};
const { count, handleDecrementClick, handleIncrementClick } = useCounter({
state: { initial: 0, max: 10 },
reducer
});
return (....) // 省略
export const useCounter = ({
state,
// 既存の reducer を internalReducer に変更する
reducer = internalReducer
}: useCounterProps): useCounterReturnType => {
const [{ count }, dispatch] = useReducer(reducer, { count: state.initial });
const handleIncrementClick = () => {
dispatch({ type: "increment", payload: { max: state.max } });
};
const handleDecrementClick = () => {
dispatch({ type: "decrement" });
};
return {
count,
handleIncrementClick,
handleDecrementClick
};
};
useCounter.reducer = internalReducer;
既存の関数にif
やswitch
で条件分岐を追加していることに気づいたら、 ロジックを上位コンポーネントから下位コンポーネントに使用するのではなく、制御を反転して下位コンポーネントにロジックを移動する方法を検討してください。
codesandbox で実装されているカウンター機能
参考文献
propsのバケツリレーをしているなら、Compoundパターン / 複合コンポーネントを考えよう
使いやすさと拡張性を両立させるため、注目されているデザインパターンで多くのデザインシステムで使用されています。
Compound を意訳すると複合、組み合わせるという意味を持ちます。
複数のネストされた子コンポーネントを複合して、UIを構築するのです。
よく具体例として挙げられるのは <select>タグ
と <option> タグ
です。
これらは単独のタグとしては役割を果たすことはできません。複合、組み合わせしてUI構築すると "何が select された状態なのか"を管理することができるのです。
Compoundコンポーネントは、親コンポーネントで暗黙的に状態を管理して、 それを複数の子コンポーネント間で共有してくれるのです。
さらに親コンポーネントと子コンポーネントを明示的に関係性を定義しており、構造の柔軟性も保っているのです。
またCompoundコンポーネントをimport
する場合、そのコンポーネントで利用可能な子コンポーネントを再度import
する必要もないのです。
まず、Compoundパターンを使用せずにCounterコンポーネント
(カウンター機能)を実装してみます。
useCounter
の状態管理に必要な引数、状態変化によるスタイリングの変更やhtmlタグに引数渡しがあり、やや混雑したコンポーネントになっているのがわかります。
const Counter = () => {
const { count, handleDecrementClick, handleIncrementClick } = useCounter();
return (
<>
<p className={"count"}>Count: <span className={10 >= count ? "red": ""}>{count}<spa</p>
<button className={"minus-button"} onClick={handleDecrementClick}>
-
</button>
<button className={"plus-button"} onClick={handleIncrementClick}>
+
</button>
</>
);
};
Compoundパターンを使用してCounterコンポーネント
(カウンター機能)を実装してみます。
整理された可読性の高いコンポーネントにすることができるのです。
const Counter = () => {
const handleChangeCounter = (count: number) => {
console.log("count", count);
};
return (
<Counter onChange={handleChangeCounter}>
<Counter.Label>
Count: <Counter.Count max={10} />
</Counter.Label>
<Counter.Increment>+</Counter.Increment>
<Counter.Decrement>-</Counter.Decrement>
</Counter>
);
}
参考文献
さいごに
根本設計も重要ではありますが、コードの小さなサインを見逃さずに中長期設計を考慮するのはより重要でしょう。悪いコードは1人の開発者でつくられるものではなく、複数人の開発者によってつくられていきます。この記事で少しでもより良い開発体験に繋がれば、非常に嬉しく思います。
間違った知識や内容がありましたら、ご指摘ください。
Discussion