プロダクトマネージャーの鈴木( @kechol )です。PMと言いつつフロントエンドを書いたりもしています。
前回の記事でバックエンドとフロントエンドの構成についてご紹介しましたが、選定の理由等まで深堀りできなかったため、今回はフロントエンドの構成についてもう少し詳しく、これまでの技術選定の振り返りも兼ねてご紹介したいと思います。
「Assured」の開発が始まったのは2020年8月頃なので、その頃からの変化を感じながらお読みいただければ幸いです。
前提
- 新規事業として過去の負債がないまっさらな状態から開発をスタートしている
- 開発するアプリケーションは Single Page Application の構成である
言語 / フレームワーク
言語は TypeScript、フレームワークは React を利用しています。
現代のフロントエンド開発において、型の恩恵を受けつつより安全に開発が可能な TypeScript をあえて使用しないことは考えにくいと思います。また、React は現在でも最も広く使われているフロントエンドフレームワークの一つであり、安定した選択肢だと思います。
ビルドツール他(create-react-app VS Next.js)
選定当時は create-react-app で作成し、その後も eject せずに利用していました。
選定した当時はまだ Next.js がそこまで広く普及しておらず、SSR(Server Side Rendering)するならNext.js、CSR(Client Side Rendering)なら create-react-app で良いのでは、くらいの風潮だったように思います。我々のアプリケーションはSSRが不要な類のものであったこともあり、当時はよりシンプルな選択をしたつもりでした。
それから2年が経ち、状況は大きく変わったように見えます。Next.js は ISR(Incremental Static Regeneration) という概念の発明から始まり、直近でも Layouts や SWC を利用した Next.js Compiler など、生産性を高めるための進化を続けています。一方で create-react-app は、フルタイムメンテナの不在により Webpack 他の依存ライブラリのアップデートさえ危うい状況となってしまっています。
実際に create-react-app から Next.js への移行を行う場合、最終的には Router ライブラリの置き換えなどビルドシステムのみならずアプリケーション層まで手を入れて移行できるのが理想です。純粋なビルドシステムの置き換えならば create-react-app からは Vite への移行のほうがよりスムーズであるという意見もあり、非常に悩ましいところです。
2023/03追記 2022年8月の時点で、ひとまずの措置としてCRAからViteに移行しました。
状態管理(Redux VS Recoil VS useReducer)
現在、状態管理は Redux や Recoil といったライブラリを使用せず、useState, useContext, useReducer といったネイティブの hooks を利用しています。
こちらの記事に詳しく記載がありますが、SaaSのようなアプリケーションでは、サーバーサイドのデータを SWR のようなキャッシュ機構をもつ Data Fetch 用のライブラリを利用して管理すると、それ以外はそれほど多くの状態を管理する必要はなくなります。
また、Redux のようなライブラリは、Redux の提供する Store を信頼できる唯一のデータソースとして利用することで最大の効力を発揮します。一度入れるとあとから引き返しにくく、入り組んだ状態を更新・反映する用途も考えづらかったので、当時からその選定はしませんでした。
結果、現状ではサーバーサイドのデータを SWR で扱いつつ、その他の状態に関しては useState, useContext を利用しています。正直なところ、useState を利用しているせいで多少非効率なレンダリングとなっているところもありますが、大きな問題にはなっていません。この点に関してはこれらの代替となりそうな Recoil が安定版となったら検討したいと思いますし、React 18 の Automatic batching にも期待しています。
一点、工夫をしているといえば useReducer です。複数APIを呼び出したり等ある程度操作が複雑なページでは、 Reducer を利用して状態遷移をシンプルに書きたい場合があります。そうした場合は複数のAPI呼び出しまでひっくるめて State として管理したいので、それができるように useThunkReducer という Hooks を作成しています。
型付け諸々をサボると以下のようなコードのイメージです。名前からお気づきの方も多いと思いますが、この発想は redux-thunk の実装と同じものです。これを使った Reducer を Page Component 内で作成すると、ページ単位で Redux と同じような状態管理ができます。
import { Dispatch, Reducer, useCallback, useReducer } from "react"; export const useThunkReducer = <S>(reducer: any, initialState: S) => { const [state, dispatch] = useReducer<Reducer<S, any>>(reducer, initialState); const thunkDispatch = ( action: | { type: string; payload: any } | ((d: Dispatch<any>, s: S) => Promise<void>) ) => { if (typeof action === "function") { action(thunkDispatch, state); } else { dispatch(action); } }; return [state, thunkDispatch]; };
スタイリング(emotion VS styled-components VS CSS Modules)
スタイリング(CSS in JS)用のライブラリは、emotion を利用しています。
選定当時は emotion が大きく変わった v11 がリリースされたばかりで、styled-components や CSS Modules を利用している会社のほうが圧倒的に多かったように思います。
それでも emotion を選んだのは書き味が良かったからです。emotion の css-prop を利用すると、装飾する要素名やCSSを別に定義することなく Component を書くことができます。
例えば、<Button /> という Component を定義するには以下のように書けます。css prop を利用してCSSはインラインで書いてしまっていますが、条件分岐や子要素からの上書きも可能で便利です。それなりにシンプルに見えるのではないでしょうか。
import React from "react"; import { useTheme } from "@emotion/react"; const Button: React.FC<React.ComponentPropsWithoutRef<"button">> = ({ children, ...props }) => { const theme = useTheme(); return ( <button css={[ { color: theme.color.white, backgroundColor: theme.color.primary, }, props.disabled && { color: theme.color.whiteDisabled, backgroundColor: theme.color.primaryDisabled, }, ]} {...props} > {children} </button> ); }; export default Button;
現在では、CSS Modulesについては Next.js 等でいまだにデファクトの選択肢ではあるものの、メンテナンスオンリーな状態にあるということで選びにくい状況にあると思います。
styled-components は今でもメジャーな選択肢だと思いますが、 emotion は Chakra UI や MUI などメジャーな Component ライブラリの内部でも利用されるようになったこともあり、他と比較してもより安定した選択肢となった印象があります。
ディレクトリ構成(Atomic Design?)
ライブラリの技術選定とは違いますが、よく議論される内容なので触れておきたいと思います。
前回の記事でもご紹介したとおり、「Assured」はクラウド利用企業とクラウド事業者のそれぞれにアプリケーションを提供しているため、簡単な Component ライブラリをローカルの npm library として実装しています。これを踏まえるといまのディレクトリ構成は以下のようなイメージです。
. ├── lib │ └── src │ ├── elements │ │ ├── theme.ts │ │ ├── Button │ │ │ ├── Button.tsx │ │ │ └── Button.test.tsx │ │ ├── Icon │ │ └── ... │ ├── hooks │ ... ├── app1 │ └── src │ ├── components │ │ ├── domainOne │ │ | ├── DomainOneAForm │ │ | ├── DomainOneBList │ │ | └── ... │ │ └── ... │ ├── pages │ │ ├── domainOne │ │ ├── domainTwo │ │ | ├── DomainTwoAPage │ │ | ├── DomainTwoBPage │ │ | └── ... │ │ └── ... │ ... └── app2 └── src ├── components ├── pages ...
ご覧のとおりですが、大きなところでは Atomic Design のように細分化した Component ごとにディレクトリを分けたりはしていません。フロントエンド専任のメンバーがいるわけではなかったこともあり、うまく Component を分けるよりはざっくり分けたほうが生産性が上がりそうだと判断した結果です。一方で、Next.js に影響を受けて pages を分けていたり、アプリ間でのテーマ共通化のために、デザイントークンを定義した theme.ts があったりします。
実際には、React のレンダリング回数を考慮して再利用性の低い Component をファイル内で分けて記述したりもしています。そういう意味では、結果的に Atomic 2.0(Atoms のレベルってつまり (Design) Tokens だよね、という主張)のような Component 設計に似ているかもしれません。
「Assured」では今のところはこの構成で生産性を保ちつつスケールできているように感じます。
Atomic Design | ディレクトリ | 再利用性 | 補足 |
---|---|---|---|
Atoms | lib/elements/theme.ts | デザイントークン的なもの | |
Molecules | lib/elements | あり | Componentライブラリ的なもの |
Cells | app/components | なし | ファイル内で分割したもの |
Organisms | app/components | なし/あり | |
Species | app/pages | なし |
話がずれますが、デザインシステムとしては Figma と連携してトークンをアップデートしたり、Storybook で Component ライブラリを整備したりといったことにも今後手を伸ばせたら良いなとも思っています。
フォーム
最近では react-hook-form を利用されている方が多いように思いますが、当時はまだ利用されているケースが少なく、また当時利用されることの多かった Formik も hooks 時代には合わない気がしたので、結局なにも使わずに独自に useForm という hooks を作って利用しています。
なお、フォームのバリデーションには yup を利用しています。
正直素直にライブラリを使っておけばよかったとも思いますが、独自にフォーム周りを実装する利点として、サーバーサイドとの親和性があります。フォームの実装ではどうしてもサーバーからのバリデーションエラーを受け取ってハンドリングするケースがありますが、アプリ固有の実装とならざるをえないそれらのハンドリングをうまく隠蔽することで、 Component 側の実装はよりシンプルになるように思います。
ご参考までに、こんな感じにフォームを実装できるイメージです(クリックで展開)。
const NameForm = () => {
const validationSchema = yup
.object()
.defined()
.shape({
name: yup.string().required(),
});
const initialValues = {
name: "",
};
const {
formData,
onSubmit,
onChange,
validationError,
isSubmitting,
} = useForm({
onSubmit: changeNameApiRequest,
onSuccess: () => console.log("Name changed."),
validationSchema,
initialValues,
});
return (
<form onSubmit={onSubmit}>
<div className="field">
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={formData.name}
onChange={onChange}
error={validationError.name}
/>
<FieldError message={validationError.name} />
</div>
<Button submit disabled={isSubmitting}>Submit</Button>
</form>
);
}
テスト(Jest, React Testing Library)
フロントエンドのテストを書くにあたっては、コスパの良い範囲で書くことを意識しました。少なくともDOMの変更が追えるように、Snapshot は各 Component のテストで取るようにしていますが、あまり細かいテストは書いていません。
フォームなど操作が正しく行えることは検証したかったので、React Testing Library を利用したインテグレーションテストを必要に応じて書いています。
一点、テストが壊れにくいように保て、という React Testing Library の思想には賛同しつつ、実際のところDOMを id や class name で指定するAPIがないのは不便だったので、そこは薄くラップした render 関数で生やしてしまいました。実装はこんなイメージです。
import React from "react"; import { render as originalRender, RenderOptions, } from "@testing-library/react"; export function render( ui: React.ReactElement, options?: RenderOptions, ) { const { container, ...rest } = originalRender(ui, { ...options }); const getById = (id: string) => container.querySelector(`#${id}`)!; const getByClassName = (className: string) => container.getElementsByClassName(className); const getByTagName = (tagName: string) => container.getElementsByTagName(tagName); return { container, getById, getByClassName, getByTagName, ...rest, }; }
まとめ
全体として、当時の選択はなるべく依存を減らしつつ、より引き返しやすい技術選定を意識していたように思います。こうして改めて振り返ってみると、ライブラリに頼ったほうが楽だったところもありそうなので、やはり技術選定は難しいなと感じます。
今はまだ小さい組織でなんでもやっている形ですが、これから「Assured」の開発をスケールさせていくにあたっては、PMを主軸におきながらフロントエンドを進めていくようなやり方にはやはり無理があります。フロントエンドにより専門性を持つ方にジョインしていただき、その方を中心としてより洗練されたフロントエンドを構築していければと考えています。
この記事を読んで興味が湧いた方がいらっしゃれば、ぜひお気軽にカジュアル面談にお申し込みいただけたらと思います。