なぜ脱jotai?
上記記事を読んでいて、なるほどーと感じたからです。
グローバル変数がどれだけ悪さをするかはチームやアプリの規模によっても変わると思います。
ただ、使わない練習もすべきだと感じました。
今回はjotaiやReduxなどのライブラリを使用せず、複数画面で同じstateを扱ってみます。
つくってみた
連続する複数の入力画面と、単一の確認画面を作成してみました。
良い感じですね。
コードの紹介
別に難しいことをする必要はなく、こんな感じの実装で実現できました。
(複雑な例じゃなかったのはよくなかったかも・・・)
import { Routes, Route, RouteObject, useNavigate } from 'react-router-dom';
import { useUserForm } from '@/hooks/useUserForm';
import { UserProfile } from '@/schemas/user';
import Index from '@/pages/register/Index';
import InputPersonalInfo from '@/pages/register/InputPersonalInfo';
import InputContact from '@/pages/register/InputContact';
import InputAddress from '@/pages/register/InputAddress';
import ConfirmUserInfo from '@/pages/register/ConfirmUserInfo';
// Fast refreshのため、本来はコンポーネントだけexportするようなファイル分割が必要
// 今回はわかりやすさ重視のため、コンポーネントと同じファイルでRouteObjectを定義する
// eslint-disable-next-line react-refresh/only-export-components
const RegisterRoutes = () => {
const { data, setData } = useUserForm();
const navigate = useNavigate();
const handleNext = (stepData: Partial<UserProfile>, nextStep: string) => {
setData({ ...data, ...stepData });
navigate(nextStep, { relative: 'path' }); // 遷移先を引数で指定
};
const handleBack = () => {
navigate(-1)
};
return (
<Routes>
<Route path="/" element={<Index onNext={() => navigate('step1')} />} />
<Route
path="step1"
element={
<InputPersonalInfo
data={data}
onNext={(stepData) => handleNext(stepData, '../step2')} // 現在パスからの相対パスor絶対パスしか指定できないため
/>
}
/>
<Route
path="step2"
element={
<InputContact
data={data}
onNext={(stepData) => handleNext(stepData, '../step3')}
onBack={handleBack}
/>
}
/>
<Route
path="step3"
element={
<InputAddress
data={data}
onNext={(stepData) => handleNext(stepData, '../step4')}
onBack={handleBack}
/>
}
/>
<Route
path="step4"
element={
<ConfirmUserInfo
data={data}
onBack={handleBack}
/>
}
/>
</Routes>
);
};
const registerRoutes: RouteObject[] = [
{
path: '/register/*',
element: <RegisterRoutes />,
}
];
export default registerRoutes;
Routeで指定しているコンポーネントの一例です。
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { personalInfoSchema, PersonalInfoFormData } from "@/schemas/user";
const InputPersonalInfo:
React.FC<{
data: PersonalInfoFormData;
onNext: (data: PersonalInfoFormData) => void;
}> = ({ data, onNext }) => {
const { register, handleSubmit, formState: { errors } } = useForm<PersonalInfoFormData>({
defaultValues: data,
resolver: zodResolver(personalInfoSchema),
});
const onSubmit = (formData: PersonalInfoFormData) => onNext(formData);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>名前</label>
<input {...register("firstName")} />
{errors.firstName && <p>{errors.firstName.message}</p>}
</div>
<div>
<label>姓</label>
<input {...register("lastName")} />
{errors.lastName && <p>{errors.lastName.message}</p>}
</div>
<div>
<label>年齢</label>
<input type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <p>{errors.age.message}</p>}
</div>
<button type="submit">次へ</button>
</form>
);
};
export default InputPersonalInfo;
いくつかポイントがあるので確認していきます!
ドキュメントを見ると以下の記載が確認できます。
Renders a branch of {@link Route |
<Routes>
} that best matches the current
location.
現在のwindowのlocationとマッチする子Routeをレンダリングする、とあります。
つまり、Routesコンポーネントはwindow.locationでレンダリングするコンポーネントを切り替えているだけとかんがえることができます。
以下のコードと似たような感じです
// pathを取得
const currentLocation = window.location.pathname
return (
<div>
// pathでコンポーネントを出し分け
{currentLocation === "1" && <Step1 data={data} onNext={handleNext} />}
{currentLocation === "2" && <Step2 data={data} onNext={handleNext} />}
</div>
)
ということで、RoutesをURLのpathによって子コンポーネントを切り替える親コンポーネントだと考えると、useState()
だけでjotaiなどのライブラリ抜きにstateを複数画面で管理できそうです。
今回の実装で言うと、以下の部分です
// Routesをレンダリングするコンポーネントでstateを定義
const { data, setData } = useUserForm();
// useUserFormは以下を隠蔽してるだけ
// export const useUserForm = () => {
// const [data, setData] = useState<UserProfile>({
// firstName: "",
// lastName: "",
// age: 0, // 初期値いれたくないけどいったんこのまま
// email: "",
// phone: "",
// address: "",
// city: "",
// postalCode: "",
// });
//
// return { data, setData }
// }
// Routes配下のRouteコンポーネントを子とみなして、stateや更新関数を渡す
return (
<Routes>
<Route path="/" element={<Index onNext={() => navigate('step1')} />} />
<Route
path="step1"
element={
<InputPersonalInfo
data={data}
onNext={(stepData) => handleNext(stepData, '../step2')} // 現在パスからの相対パスor絶対パスしか指定できないため
/>
}
/>
RouteObject
また、router側で使用してもらうため、ファイル下部で以下のようにexportしています。
const registerRoutes: RouteObject[] = [
{
path: '/register/*',
element: <RegisterRoutes />,
}
];
export default registerRoutes;
App.tsx側はこんな感じ
import { RouteObject, createBrowserRouter, RouterProvider } from 'react-router-dom';
import registerRoutes from './routes/RegisterRoutes';
const mainRoutes: RouteObject[] = [
...registerRoutes
];
const router = createBrowserRouter(mainRoutes);
function App() {
return <RouterProvider router={router} />;
}
export default App;
Routeコンポーネント
子コンポーネントとして使っているコンポーネントも確認します
// ユーザーの名前、姓、年齢といった基本的な個人情報を含むスキーマ
export const personalInfoSchema = userSchema.pick({
firstName: true,
lastName: true,
age: true,
});
export type PersonalInfoFormData = z.infer<typeof personalInfoSchema>;
import { personalInfoSchema, PersonalInfoFormData } from "@/schemas/user";
const InputPersonalInfo:
React.FC<{
data: PersonalInfoFormData;
onNext: (data: PersonalInfoFormData) => void;
}> = ({ data, onNext }) => {
schemaで定義した、ユーザーstateの一部だけをpropsで受け取っています。
このような設計にしたことで、このコンポーネントでどのstateが更新されるのか?が把握しやすくなっています。
(globalなstateだと、ここら辺の調査がしんどくなっていくイメージ)
以上です!
簡単なサンプルだったので脱jotaiできたか若干微妙ですが、一応できたということにします!
さいごに
グローバルなstateは影響範囲、更新の追跡などが難しくデバッグを困難にします。
どこまでその問題を重視するかはプロジェクトやチームの規模や関係性、フェーズによって異なると思います。
そういったリスクがあるよ~という勉強ができたので、ついでにそういったリスクを避ける実装も勉強してみました。
おもろかったです