React Hooksの目新しさも落ち着いてきて、様々な現場で当然のように見るようになってきました。
私がここ1年使ってきてHooksについて思うことを、まとめていきます。
React Hooksによって何が変わったか
React Hooksについて、公式には以下のようにあります。
フック (hook) は React 16.8 で追加された新機能です。state などの React の機能を、クラスを書かずに使えるようになります。
これだけ理解すると、
Class ComponentをFunctional Componentに書き換えられるだけでしょ?
ちょっとモダンな書き方になるだけで、本質は何も変わってないんでしょ?
と思ってしまいがちです。
class Toggle extends React.Component {
constructor(props) {
super(props);
this.state = { isOpen: true };
}
handleClick() {
this.setState(state => ({ isOpen: !state.isOpen }));
}
render() {
return (
<button onClick={ this.handleClick }>
{ this.state.isOpen ? 'ON' : 'OFF' }
</button>
);
}
}
↓
const Toggle = () => {
const [isOpen, setIsOpen] = useState<boolean>(true);
const handleClick = () => setIsOpen(!isOpen);
return (
<button onClick={ handleClick }>
{isOpen ? 'ON' : 'OFF'}
</button>
)
}
パッと見すっきりしてるし、なんとなく良い書き方っぽい。
だからただHooks使えばなんとなくモダンな感じがする。
しかしHooksがそんなに浅いものだったら、ここまで話題にはなりません。
React Hooksの本質はそこではなく、Custom Hooksにあるのです。
Custom Hooks
Custom Hooksとは、簡単に言えば「Hooksを使用した関数」です。
それだけだと物凄いシンプルな話なんですが、見た目以上に強力な概念です。
export const useToggle = (initialValue: false) => {
const [isOpen, setIsOpen] = useState<boolean>(initialValue);
return {
isOpen,
open: useCallback(() => setIsOpen(true), []),
close: useCallback(() => setIsOpen(false), []),
toggle: useCallback(() => setIsOpen(!isOpen), [isOpen])
};
};
状態とそれに対する操作のスコープを制限することができる
Class内には、そのコンポーネントの持つ全ての状態が列挙されます。
バケツリレー的な書き方が推奨されることが多い関係上、そのRootComponentにはあらゆる状態が集まりがちでした。
class UserShowContainer extends Component {
constructor(props: Props) {
super(props);
this.state = {
isLoading: true,
isError: false,
isLogouting: false,
user: null,
item: null,
isItemDialogOpen: false,
isDeleteDialogOpen: false,
...
};
...
}
これらの状態には、お互いに関係性のないもの多くあります。
しかしこれらは「そのComponentに属する」だけで全て同じスコープとして扱われます。
そのため可読性が落ち、バグの温床になることも多々ありました。
たとえばAPIのエラー判定をisError
で行っていたとします。
async fetchUser() {
try {
this.setState({ isLoading: true });
await apiClient.fetchUser();
this.setState({ isLoading: false, isError: false });
} catch (e) {
this.setState({ isLoading: false, isError: true });
}
}
追加の実装でこんなコードが書き足されたとします。
async fetchItem() {
try {
this.setState({ isLoading: true });
await apiClient.fetchItem();
this.setState({ isLoading: false, isError: false });
} catch (e) {
this.setState({ isLoading: false, isError: true });
}
}
お互いに同じisLoading
とisError
を操作してしようとしまって、たとえばエラーのはずが処理の順番でエラー扱いではなくなってしまったという不具合につながります。
もちろんこれだけ見ると「こんなコード書くほうが悪い」って話ですが、Componentが肥大化していくとこういう問題を防ぐのは難しくなります。
Redux等も書き込み自由なグローバル変数のように扱われがちで、こうした「状態に対する意図しない操作」を防ぐことが困難でした。
一方Custom Hooksを使えば、状態とそれに対する操作のスコープを制限することができます。
const useFetchUser = () => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const [user, setUser] = setState<User | null>(null);
const fetch = async () => {
try {
setIsLoading(true);
setUser(await fetchUser());
setIsError(false);
} catch {
setIsError(true);
} finally {
setIsLoading(false);
}
});
useEffect(() => { fetch() }, []);
return {
user,
isLoading,
isError
}
}
この書き方において、isLoading
、user
といった状態が外部から操作される危険性は全くありません!
setterをスコープ内に閉じているため、外部で意図しない操作に晒される心配をしなくて良いのです。
ただuser
, isLoading
, isError
が正しく返却されることのみ担保されれば良いので、テスタビリティも向上します。
また同じ挙動が担保されるのであれば、内部の実装の変更は自由です。
後からReduxで書き換えることもできますし、事情があって状態の持ち方を変える場合も柔軟に対処可能です。
たとえば次の例ではisOpen
をboolean
からnumber
に変えていますが、影響範囲はCustom Hooksのスコープ内のみに閉じ込められています。
export const useToggleBool = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
return {
isOpen,
open: () => setIsOpen(true),
close: () => setIsOpen(false)
};
};
export const useToggleNumber = () => {
const [isOpen, setIsOpen] = useState<number>(0);
return {
isOpen: isOpen === 1,
open: () => setIsOpen(1),
close: () => setIsOpen(0)
};
};
またStateに対して許可する操作のみを関数化できるため、意図しない値が入ることを防ぐことが可能です。
例えば上記のuseToggleNumber
において、isOpen
に0
か1
以外の値が入ることを想定しなくても良いのです。
状態とそれに対する操作を使い回すことができる
Custom Hooksを定義することで、状態と、その状態に対する操作を使いまわすことができます。
同じ性質を持つ状態を複製的に作るのは、ReduxやClass Componentでは難しかったことです。
const HomeComponent = () => {
const userDialog = useToggle();
const itemDialog = useToggle();
return (
<>
<UserCard onClick={userDialog.open} />
<Button onClick={itemDialog.toggle} />
{userDetailDialog.isOpen && <UserDialog onClose={userDialog.close} />}
{itemDialog.isOpen && ItemDialog />}
</>
)
}
もちろんClass Componentにも、高階コンポーネントなどロジックの再利用を行う手段はありました。
が、どうしても複雑になりがちでした。
気軽に再利用ができるようになったことで、たとえば以下のような「HeadlessなUIライブラリ」も生まれきています。
ex) https://github.com/tannerlinsley/react-table
今までは再利用性の高いComponentを持つことが生産性向上につながり、企業は独自のComponent Libraryを作るなどしてこれを図ってきました。
今後はロジックのみをそこから切り出し、UIを交換可能にすることができるようになります。
つまりいかに使い回しの効くHooksを持っているかも、企業戦略として重要になってくるでしょう。
状態をComponentから切り離すことができる
Class Componentでは、「UIを作り上げるためのロジック」や「状態を変化させるためのロジック」がクラス内に混合しがちでした。
そのためReduxなどのFlux Architectureが普及し、ComponentではUIの生成のみに注力する書き方が主流となりました。
しかしHooksにより、状態をComponentから切り離すことが可能となりました。
そのため状態の分離のために、Reduxに頼る必要がなくなったのです。
特にReduxはSingletonな1つのStateをProjectで共有するため、一部の状態のみを独立して扱うことが困難でした。
他の状態からも切り離し可能になったことで、フロントエンド設計の柔軟性は大幅に向上しました。
React Hooksで解き放たれた状態なにで縛るか
さて、React Hooksにより「状態」はComponentから時なはたれ、自由となりました。
しかしルールのない自由は、ただの混沌です。
Custom Hooksも、どの粒度で、どういう単位で設計すれば良いのか、利用者に委ねられています。
Custom HooksからCustom Hooksを呼び出すこともできるため、その設計の自由度は計り知れません。
では、どのようなルールでCustom Hooksを設計すれば良いのでしょうか?
クラス定義っぽい?
まずCustom Hooksを書いてて思ったのが、クラス定義っぽい!ってことです。
const toggleA = useToggle(); // const toggleA = new Toggle();っぽい
const toggleB = useToggle(); // const toggleB = new Toggle();っぽい
if (toggleA.isOpen && toggleB.isOpen) {
toggleA.close();
}
getterとsetterを内部に隠蔽し、利用者に必要な操作のみを提供するのはカプセル化っぽい!
「じゃあ試しにhooksをドメインモデル単位にして、モデルに対して可能な操作をそこで許可したらどうだろう?」と、試してみました。
const useUser = (userId: number) => {
...
return {
destroy: () => ...,
fetch: () => ...,
isLoading
};
}
確かに使ってる分にはそれっぽい。
user.destroy
だのuser.isLoading
だの、使ってる分にもわかりやすい。
雑に便利に使える感じはRailsっぽい。
ただ1つのhooksが担保する責務が広すぎました!
クラス定義はただのデータの型なのに対し、HooksはComponentに紐づく状態や、場合によってはAPIアクセエスも含みます。
気軽に使いまわせるものではなく、どうしても利用側の状況に合わせちょっとした差異が生じてきます。
結局のところそうしたモデルに対する操作の定義はClassに任せ、HooksはそのClassを扱う程度に止めるのが良いと感じました。
クラス定義と同一視するには、さすがにHooksの持つ意味合いは広すぎました。
Component始点でHooksを定義する
この反省を踏まえて考え直すに、hooksはあくまでComponentの持つ状態の延長線上です。
hooks設計の視点としてComponentが始点となるのは避けられないと感じました。
ということで、まずはComponentの持つ状態を整理します。
このコンポーネントの持つ状態は何か?
- APIの読み込み状態
- APIの取得の結果エラーだったか
- 検索フォームに撃ち込まれた文字
- ダイアログの開閉状態
その上で、互いに関係性のある状態をグルーピングし、その最小単位をCustom Hooksにしていきます。
const useFetchUser = () => {
const [user, setUser] = useState<User>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
...
}
export const useInput = () => {
const [value, setValue] = useState<string>('');
return {
value,
onChange: (e: React.FormEvent<HTMLInputElement>) =>
setValue(e.currentTarget.value)
};
};
用途からではなく、本質的な意味単位で状態設計を行う
このとき利用者側の基準で状態設計すると、1つの状態の持つ意味合いが広くなりがちです。
たとえば初期化中はローディングを出したいと思ってisLoading
という状態設計すると下記のようになります。
const [isLoading, setIsLoading] = useState<boolean>(false);
setIsLoading(true);
await Promise.all([fetchUser(), fetchCompany(), fetchItems()]);
setIsLoading(false);
}
しかしこれだと1つのCustom Hooksの持つ意味が広くなりすぎ使い回しが効きづらくなります。
また後に「ユーザがロード中の時だけユーザのアイコンを変えたい!」となったとき、変更が大変です。
fetchUser
のisLoading
とfetchCompany
のisLoading
は本質的に互いに関係がない値です。
より本質的な意味単位で状態設計し、そこから各UIの事情に合わせ値を計算したほうが良いでしょう。
const fetchUser = useFetchUser();
const fetchCompany = useFetchCompany();
const fetchItems = useFetchItems();
const isLoading = fetchUser.isLoading || fetchCompany.isLoading || fetchItems.isLoading;
Custom Hooks内で共通する処理は、さらに抽象化する
各Custom Hooksで共通するような処理は、より汎用的なCustom Hooksで抽象化していきます。
Componentの事情に近いCustom Hooksから、より抽象化の高いCustom Hooksを呼び出すような構造となります。
export const useFetchUser = () => {
const [user, setUser] = useEffect<User | null>(null);
const fetch = useAsync(async () => setUser(await userApi.fetch()));
....
}
export const useFetchCompany = () => {
const [company, setCompany] = useEffect<Company | null>(null);
const fetch = useAsync(async () => setCompany(await companyApi.fetch()));
...
}
export const useAsync = (callback) => {
const [isLoading, setIsLoading] = useEffect<boolean>(false);
const [isError, setIsError] = useEffect<boolean>(false);
...
}
課題
ただこの考え方でも、まだしっくりこない点があります。
たとえば編集画面でuseFetchUser
のuser
を編集したいときです。
useFetchUser
の中にsetUserName
などを生やすと、このCustom Hooksの責務がどんどん広くなっていきます。
またsetUser
を返すようにすると、どんどんガバガバになっていき、Custom Hooksのメリットは薄れてしまいます。
パラメータや戻り値でなんとかしていこうとすると、運用が続くにあたりCustom Hooksに渡すパラメータは増え、戻り値もどんどん増えていきます。
この辺りは適度にバランス感を取りつつ、継続的なリファクタリングを行う必要があるでしょう。
おわりに
というわけで確信めいた結論は出ていないものの、現時点での考えを記事にまとめました。
実際のHooksの例としては、下記Repsoitoryが非常に参考になるので、最後に紹介だけしておきます!
https://github.com/streamich/react-use