1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【脱jotai】複数画面をまたぐフォームくらいuseStateだけで実装してみたい

Posted at

なぜ脱jotai?

cap2.PNG

上記記事を読んでいて、なるほどーと感じたからです。

グローバル変数がどれだけ悪さをするかはチームやアプリの規模によっても変わると思います。
ただ、使わない練習もすべきだと感じました。
今回はjotaiやReduxなどのライブラリを使用せず、複数画面で同じstateを扱ってみます。

つくってみた

連続する複数の入力画面と、単一の確認画面を作成してみました。

sample.gif

良い感じですね。

コードの紹介

別に難しいことをする必要はなく、こんな感じの実装で実現できました。
(複雑な例じゃなかったのはよくなかったかも・・・)

src/routes/RegisterRoutes.tsx
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で指定しているコンポーネントの一例です。

src/pages/register/InputPersonalInfo.tsx
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;

いくつかポイントがあるので確認していきます!

Routesコンポーネント
cap1.PNG

ドキュメントを見ると以下の記載が確認できます。

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側はこんな感じ

src/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コンポーネント

子コンポーネントとして使っているコンポーネントも確認します

schemas/user.ts
// ユーザーの名前、姓、年齢といった基本的な個人情報を含むスキーマ
export const personalInfoSchema = userSchema.pick({
    firstName: true,
    lastName: true,
    age: true,
});

export type PersonalInfoFormData = z.infer<typeof personalInfoSchema>;
src/pages/register/InputPersonalInfo.tsx
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は影響範囲、更新の追跡などが難しくデバッグを困難にします。

どこまでその問題を重視するかはプロジェクトやチームの規模や関係性、フェーズによって異なると思います。
そういったリスクがあるよ~という勉強ができたので、ついでにそういったリスクを避ける実装も勉強してみました。

おもろかったです

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?