Container Componentに書いていたlogicをCustom Hooksに書いてみる
はじめに
みなさんは、Reactを使用して開発する時、Componentで使用するlogicをどこに実装していますか?
私はComponentをContainer Component
とPresentational Component
の二つに分けて、Container Component
にlogicを書いてました。
しかし、Custom Hooks
を使用することでわざわざComponentを分けなくても良い、ということを最近知りました。
本記事は、それを実践してみよう、というものです。
まず用語を簡単に確認してから、サンプルコードを用いてCustom Hooks
を実装していきたいと思います。
Container Componentとは
Dan Abramov氏のブログから引用します。
You’ll find your components much easier to reuse and reason about if you divide them into two categories. I call them Container and Presentational components* ...
ComponentをContainerとPresentationalに分けるとComponentがより再利用しやすくなります、と書いてあります。
同じブログ内で、Container Componentはどのように動くかに関心を持ち、Presentational Componentはどのように見えるかに関心を持つ、とも説明してます。
つまり、この書き方を使用するとComponentで使用するlogicはContainer Componentに実装することがわかります。
Custom Hooksとは
一方、Custom Hooksとはどういったものかも見ていきましょう。
Hooksとは
Reactの公式ドキュメントから引用します。
フック (hook) は React 16.8 で追加された新機能です。state などの React の機能を、クラスを書かずに使えるようになります。
Hooksが登場する以前は、Componentにstateを持たせたり、ライフサイクルメソッドを使用したりするには、Class Componentで書く必要がありました。
しかし、Hooksを用いることで、 Functional Componentでも同じことができるようになったのです。
Reactからは、useStateやuseEffect、useContextといったHooksが提供されています。
Custom Hooksとは
ではCustom Hooksとは何でしょうか。こちらも公式ドキュメントから引用します。
カスタムフックとは、名前が ”use” で始まり、ほかのフックを呼び出せるJavaScript の関数のことです。
これをみるとCustom Hooksとはただの関数ということがわかります。
自分独自のフックを作成することで、コンポーネントからロジックを抽出して再利用可能な関数を作ることが可能です。
また、この部分をみると本記事でやろうとしていることとリンクしていることがわかると思います。
Example
ここからはContainer/Presentational Componentで書かれたコードをCustom Hooksを使用したコードに書き換えてみようと思います。
使用してるコードは下記リポジトリにあげてるので興味ある方はぜひご覧ください。
https://github.com/hakushun/lt_sample-custom-hooks
なお、今回使用してるサンプルコードは、Reactを勉強し始めて1年の(エンジニア歴も1年の)人間がこれでいいのか?と考えながら書いたものになりますので、イケてない部分が多くあるかと思いますが、ご了承いただければと思います。
前提
- ReactはNext.jsの上で動かしてます。
- 個人開発用に認証周りのボイラープレートを作成しようとしていたリポジトリの断面です。
- firebase authenticationを使用してます。(理解してなくても問題ないです。)
Example 1: 認証系ロジックの書き換え
一つ目の例では、サインアップ、サインインやログアウト関連のロジックをCustom Hooksに移していきたいと思います。
Container Component
まずContainer ComponentにあるCustom Hooksに移していきたいlogicを確認します。
- Container/src/components/SignUp/index.tsx
- 対象:signup関数
export const SignUp: React.VFC = () => {
const router = useRouter();
const dispatch = useDispatch();
const signup = async (value: { email: string; password: string }) => {
const { email, password } = value;
try {
await firebase.auth().createUserWithEmailAndPassword(email, password);
router.push('/mypage');
} catch (error) {
dispatch(emitError(alertError(error)));
}
};
return <Presentational signup={signup} />;
- Container/src/components/SignIn/index.tsx
- 対象:signin関数
export const SignIn: React.VFC = () => {
const router = useRouter();
const dispatch = useDispatch();
const signin = async (value: { email: string; password: string }) => {
const { email, password } = value;
try {
await firebase.auth().signInWithEmailAndPassword(email, password);
router.push('/mypage');
} catch (error) {
dispatch(emitError(alertError(error)));
}
};
return <Presentational signin={signin} />;
};
- Container/src/components/AuthForm/index.tsx
- 対象:signinWithGoogle関数
export const AuthForm: React.VFC<Props> = ({ type, onSubmit }) => {
const router = useRouter();
const dispatch = useDispatch();
const dialogIsOpened = useSelector(selectDialog);
const dialogMessage = useSelector(selectDialogMessage);
const googleProvider = new firebase.auth.GoogleAuthProvider();
const signinWithGoogle = async () => {
try {
await firebase.auth().signInWithPopup(googleProvider);
router.push('/mypage');
} catch (error) {
dispatch(emitError(alertError(error)));
}
};
return (
<Presentational
isOpend={dialogIsOpened}
message={dialogMessage}
type={type}
onSubmit={onSubmit}
signinWithGoogle={signinWithGoogle}
/>
);
};
- Container/src/components/Header/index.tsx
- 対象:isAuthステート、logout関数
export const Header: React.VFC = () => {
const router = useRouter();
const dispatch = useDispatch();
const isAuth = useSelector(selectIsAuth);
const logout = async () => {
try {
await firebase.auth().signOut()
dispatch(authUser(false));
router.push('/');
} catch (error) {
dispatch(emitError(alertError(error)));
}
}
return <Presentational isAuth={isAuth} logout={logout} />;
};
Custom Hooks
今回はuseAuth
というCustom Hooksを作成して、そこに上であげたlogicを移しました。
- CustomHooks/src/hooks/useAuth.ts
export const useAuth: UseAuthType = () => {
const router = useRouter();
const dispatch = useDispatch();
// Container/src/components/Header/index.tsxから移植
const isAuth = useSelector(selectIsAuth);
// Container/src/components/SignUp/index.tsxから移植
const signup = async (value: { email: string; password: string }) => {
const { email, password } = value;
try {
await firebase.auth().createUserWithEmailAndPassword(email, password);
router.push('/mypage');
} catch (error) {
dispatch(emitError(alertError(error)));
}
};
// Container/src/components/SignIn/index.tsxから移植
const signin = async (value: { email: string; password: string }) => {
const { email, password } = value;
try {
await firebase.auth().signInWithEmailAndPassword(email, password);
router.push('/mypage');
} catch (error) {
dispatch(emitError(alertError(error)));
}
};
// Container/src/components/AuthForm/index.tsxから移植
const googleProvider = new firebase.auth.GoogleAuthProvider();
const signinWithGoogle = async () => {
try {
await firebase.auth().signInWithPopup(googleProvider);
router.push('/mypage');
} catch (error) {
dispatch(emitError(alertError(error)));
}
};
// Container/src/components/Header/index.tsxから移植
const logout = async () => {
try {
await firebase.auth().signOut()
dispatch(authUser(false));
router.push('/');
} catch (error) {
dispatch(emitError(alertError(error)));
}
}
return { isAuth, signup, signin, signinWithGoogle, logout };
};
Component側では、作成したuseAuthを呼び出して使用するロジックを取り出します。
これでContainer/Presentationalに分ける必要がなくなります。
- CustomHooks/src/components/SignUp/index.tsx
export const SignUp: React.VFC = () => {
const { signup } = useAuth();
return <AuthForm type="signup" onSubmit={signup} />;
};
- CustomHooks/src/components/SignIn/index.tsx
export const SignIn: React.VFC = () => {
const { signin } = useAuth();
return (
<>
<AuthForm type="signin" onSubmit={signin} />
<div className={styles.root}>
If you do not have an account, please{' '}
<Link href="/signup">
<a>Create Account</a>
</Link>
</div>
</>
);
};
- CustomHooks/src/components/AuthForm/index.tsx
- ダイアログ関連のステートが残ってるので、まだContainer/Presentationalのママ
export const AuthForm: React.VFC<Props> = ({ type, onSubmit }) => {
const { signinWithGoogle } = useAuth();
const dialogIsOpened = useSelector(selectDialog);
const dialogMessage = useSelector(selectDialogMessage);
return (
<Presentational
isOpend={dialogIsOpened}
message={dialogMessage}
type={type}
onSubmit={onSubmit}
signinWithGoogle={signinWithGoogle}
/>
);
};
- CustomHooks/src/components/Header/index.tsx
export const Header: React.VFC = () => {
const { isAuth, logout } = useAuth();
return (
<header className={styles.header}>
~ 省略 ~
</header>
);
};
Example 2: ダイアログ関連ロジックの書き換え
Example 1で実施した書き換えだけだと、AuthForm.tsxがまだlogicの抽出が完全に終わっていません。
そのため、この例ではそこで使用されているダイアログ関連のロジックをCustom Hooksに移していこうと思います。
Container Component
- Container/src/components/AuthForm/index.tsx
- 対象:dialogIsOpenedステート、dialogMessageステート
export const AuthForm: React.VFC<Props> = ({ type, onSubmit }) => {
const { signinWithGoogle } = useAuth();
const dialogIsOpened = useSelector(selectDialog);
const dialogMessage = useSelector(selectDialogMessage);
return (
<Presentational
isOpend={dialogIsOpened}
message={dialogMessage}
type={type}
onSubmit={onSubmit}
signinWithGoogle={signinWithGoogle}
/>
);
};
- Container/src/components/Dialog/index.tsx
- 対象:closeDialog関数
export const Dialog: React.FC<Props> = ({ message }) => {
const dispatch = useDispatch();
const closeDialog = () => {
dispatch(toggle());
};
return <Preasentational message={message} closeDialog={closeDialog} />;
};
Custom Hooks
useDailog
というCustom Hooksを作成し、そこにlogicを移しました。
- CustomHooks/src/hooks/useDialog.ts
export const useDialog: UseDialogType = () => {
const dispatch = useDispatch();
// Container/src/components/AuthForm/index.tsxから移植
const isOpend = useSelector(selectDialog);
const message = useSelector(selectDialogMessage);
// Container/src/components/Dialog/index.tsxから移植
const closeDialog = () => {
dispatch(toggle());
};
return { isOpend, message, closeDialog };
};
先ほどの例と同様に、これをComponentで使用します。
これでAuthFormもDialogもContainer/Presentationalに分ける必要がなくなりました。
- Container/src/components/AuthForm/index.tsx
export const AuthForm: React.VFC<Props> = ({ type, onSubmit }) => {
const { isOpend, message } = useDialog();
const { signinWithGoogle } = useAuth();
return (
<>
~ 省略 ~
</>
);
};
- Container/src/components/Dialog/index.tsx
export const Dialog: React.FC<Props> = ({ message }) => {
const { closeDialog } = useDialog();
return (
<Overlay>
<div className={styles.dialog}>
<div className={styles.title}>{message.title}</div>
<div className={styles.description}>
<div>{message.description}</div>
</div>
<div className={styles.buttonWrapper}>
<button className={styles.button} type="button" onClick={() => closeDialog()}>
閉じる
</button>
</div>
</div>
</Overlay>
);
};
(おまけ)Example 3: HOCの書き換え
こちらはContainer Componentではなく、HOCのlogicをCustom Hooksに移す例となります。
本題から少しずれるのでおまけとして記載します。(内容にも自信がありません。。。)
※HOCとは ⇒ https://ja.reactjs.org/docs/higher-order-components.html
HOC
認証が必要なComponentをラップして、ログインしてればそのままラップしたComponentを描画し、ログインしてなければトップページに遷移させるHOCです。
useEffectの中でfirebaseの関数を呼んで、loginしてるかどうかを確認してます。
- Container/src/helpers/withAuth.tsx
- 対象:useEffect
export const withAuth = (Component: React.FC): React.FC => (props: any) => {
const dispatch = useDispatch();
const router = useRouter();
const isAuth = useSelector(selectIsAuth);
useEffect(() => {
const cancelAuthListener = firebase.auth().onIdTokenChanged(async (user) => {
if (user) {
dispatch(authUser(true));
} else {
dispatch(authUser(false));
router.push('/');
}
});
return () => {
cancelAuthListener();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <>{!isAuth ? <PageLoader /> : <Component {...props} />}</>;
};
このHOCのComponent側での使い方は下記のようになります。
- Container/src/components/Mypage/index.tsx
const Component: React.VFC = () => <div className={styles.root}>You’re Authenticated!</div>;
export const Mypage = withAuth(Component);
Custom Hooks
先ほど作ったuseAuthの中にuseEffectを移してきます。
ログインしてないときの処理に、SignIn/SignUpページにいるときはそのページから遷移しないようにlogicを書き足してます。
- CustomHooks/src/hooks/useAuth.ts
export const useAuth: UseAuthType = () => {
〜 省略 〜
// Container/src/helpers/withAuth.tsxから移植
useEffect(() => {
const cancelAuthListener = firebase.auth().onIdTokenChanged(async (user) => {
if (user) {
dispatch(authUser(true));
} else {
dispatch(authUser(false));
if (router.pathname === '/signin' || router.pathname === '/signup') {
router.push(router.pathname);
return;
}
router.push('/');
}
});
return () => {
cancelAuthListener();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { isAuth, signup, signin, signinWithGoogle, logout };
};
Component側では下記のように使用します。
- CustomHooks/src/components/Mypage/index.tsx
export const Mypage: React.VFC = () => {
const { isAuth } = useAuth();
return <>{isAuth ? <div className={styles.root}>You’re Authenticated!</div> : <PageLoader />}</>;
};
おわりに
いかがでしたでしょうか。
まだCustom Hooksをうまく使いこなせていない中で書いた記事ですので、わかりづらいかもしれませんが、Custom Hooksを使ってみようと思ってる人の理解の一助になればいいなと思ってます。
最後までご覧いただきありがとうございました!!
参考資料
Container Component
- https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0
- https://medium.com/@learnreact/container-components-c0e67432e005
Hooks
- https://reactjs.org/docs/hooks-intro.html
- https://medium.com/@dan_abramov/making-sense-of-react-hooks-fdbde8803889
Discussion