技術的負債は「しゃあない」で積み上がる ── 実務コードで見るリアルな成れの果て
技術的負債は「しゃあない」で積み上がる ── 実務コードで見るリアルな成れの果て
みなさん、こんにちは!フロントエンドエンジニアの @nyaomaru です!
最近、AI と話す時間の方が、人間と話す時間より長くなった気がしています。
けれどもコードだけは、どっちと話しても結局こうなります。
技術的負債。
本記事は「技術的負債ってなんやねん?」の続編です。
この記事では、技術的負債がどう積み上がるか を、コードベースで実演します。
できるだけリアルに、「実際にありそうな(あった)改修地獄」 を再現しました。
最初はきれいなコード。
でも追加要件を受け入れ続けるうちに、どうなるか?
ほんなら、一緒に見てこな!
💸 技術的負債は、こうして静かに積み上がる
初めから dirty なコードなんて誰も書かへん。
review で品質を担保してれば、ある程度は防げると思っとる。
最近やったら、Copilot や Code Rabbit みたいな AI review もあるしな。
致命的なコードは、ほんまに減ってきた。
でも、“しゃあない”を言い訳にした小さな積み上げが、確実に未来を壊していくねん。
コードの 1 行 1 行よりも怖いのは、仕様の積み増しと時間切れの判断ミスや。
- 規模が肥大化してきたり
- 機能追加による改修が重なったり
- 既存コードが誤解されたり
- 共通化・汎用化されなかったり
── こういうもんが積み重なって、技術的負債は静かに、けど確実に増殖していく。
今回はその中でも、
「機能追加による改修が重なる」 パターンを、React
を使って見ていくで~!
Next.js
+ zod
+ RHF
+ shadcn
で構成してる前提な!
🔧 「あと 1 個だけ」改修の連打が未来を壊す
フォーム画面作ってるときに、よくあるやろ?
「ステップ形式のフォームにしよや」
そこまではええ。
せやけど、そのあとや。
追加要件が来るたびに、「とりあえず動くようにしとこう」 で逃げ続けたコードの末路。
既存コードに無理やり条件ねじ込んで、回避策を連打しまくった地獄を想定してみよか。
まずは
「通常注文」のフォームを作ったんや。
これはまだ機能としても薄いし、全然読みやすい。
この時点では、リファクタするほどでもないな。
時間に余裕あったらしてもええけどな、ってレベルや。
features/
└── order-form/
├── components/
│ └── OrderForm.tsx
🚨 最初はこんな感じやった(通常注文だけ)
// OrderForm 実装例
import { useState } from 'react';
type Form = {
customerName: string; // Step1
email: string; // Step1
orderId: number; // Step2
phone: string; // Step2
};
// 注文フォーム
export function OrderForm() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Form>({
customerName: '',
email: '',
orderId: 0,
phone: '',
});
const submit = async () => {
try {
await api.post('/orders', formData);
alert('注文が完了しました!');
} catch (error) {
console.error(error);
alert('注文に失敗しました。');
}
};
return (
<div>
{step === 1 && (
<StepOne
data={formData}
setFormData={setFormData}
onNext={() => setStep(2)}
/>
)}
{step === 2 && (
<StepTwo
data={formData}
setFormData={setFormData}
onBack={() => setStep(1)}
onSubmit={() => submit()}
/>
)}
</div>
);
}
よっしゃ、ほんなら機能追加するで~
さて、次に。
「特別注文」に対応せなあかんようになったとしよか。
変な値いれて実行されたら困るから、バリデーションを追加したわ。
あと、肝心な注文 ID を入れてもらわなあかんから、Step も追加したで。
さっきまではシンプルやった実装が、ここで一気に見づらくなっていく。
いってみよか。
🔥 改修が重なるとこうなる(特別注文追加)
// 🔥 特別注文が入った後の OrderForm 実装例
import { useState, useEffect } from 'react';
import { stepOneSchema, stepTwoSchema, stepThreeSchema } from './formSchema';
type Form = {
customerName: string; // Step1
email: string; // Step1
phone: string; // Step2
orderId: number; // Step2 から Step3 に移動
discountCode?: number; // Step3 (specialな場合だけ)
};
export function OrderForm({ isSpecial }: { isSpecial: boolean }) {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Form>({
customerName: '',
email: '',
phone: '',
orderId: 0,
discountCode: undefined,
});
// special な実装を追加
useEffect(() => {
if (isSpecial) {
// 特別注文の場合だけ discountCode を初期化
setFormData((prev) => ({
...prev,
discountCode: 666, // 仮初期値をセット
}));
}
}, [isSpecial]);
const handleNextStep = async () => {
try {
if (step === 1) {
stepOneSchema.parse(formData);
setStep(2);
} else if (step === 2) {
stepTwoSchema.parse(formData);
setStep(3);
}
} catch (e) {
console.error(e);
}
};
const handleSubmit = async () => {
try {
stepThreeSchema(isSpecial).parse(formData);
await api.post('/orders', formData);
alert('注文が完了しました!');
} catch (e) {
console.error(e);
alert('注文に失敗しました。');
}
};
return (
<div>
{step === 1 && (
<StepOne
data={formData}
setFormData={setFormData}
onNext={handleNextStep}
/>
)}
{step === 2 && (
<StepTwo
data={formData}
setFormData={setFormData}
onBack={() => setStep(1)}
onNext={handleNextStep}
/>
)}
{step === 3 && (
<StepThree
data={formData}
setFormData={setFormData}
onBack={() => setStep(2)}
onSubmit={handleSubmit}
showDiscountField={isSpecial}
/>
)}
{isSpecial && <NoteForSpecialOrder />}
</div>
);
}
😇 次第に・・・
ほんでさらに。
「管理者注文」っちゅう、特別な注文ができるようになったとしよ。
機能をさらに足していくと、もう読みづらいどころの話ちゃう。
保守するたびに、心折れるレベルになってきてる。
特にヤバいんが、コンポーネント内に増殖する if 条件や。
見てみ。
// 🔥 特別注文に加えて、管理者注文が追加された OrderForm 実装例
import { useState, useEffect } from 'react';
import {
stepOneSchema,
stepTwoSchema,
stepThreeSchema,
stepFourSchema,
} from './formSchema';
type Form = {
customerName: string; // Step1
email: string; // Step1
phone: string; // Step2
orderId: number; // Step2 から Step3 に移動
discountCode?: number; // Step3 (specialな場合だけ)
remarks?: string; // Step4 (adminな場合だけ)
};
export function OrderForm({
isSpecial,
isAdmin,
}: {
isSpecial: boolean;
isAdmin: boolean;
}) {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState<Form>({
customerName: '',
email: '',
phone: '',
orderId: 0,
discountCode: undefined,
remarks: undefined,
});
useEffect(() => {
if (isSpecial) {
// 特別注文なら discountCode を初期化
setFormData((prev) => ({
...prev,
discountCode: 666,
}));
}
}, [isSpecial]);
const handleNextStep = async () => {
try {
if (step === 1) {
stepOneSchema.parse(formData);
setStep(2);
} else if (step === 2) {
stepTwoSchema.parse(formData);
setStep(3);
} else if (step === 3) {
stepThreeSchema(isSpecial).parse(formData);
// Adminユーザーなら Step4 へ、それ以外なら Submit
if (isAdmin) {
setStep(4);
} else {
await handleSubmit();
}
}
} catch (e) {
console.error(e);
}
};
const handleSubmit = async () => {
try {
// Step4がある場合は、stepFourSchemaもバリデート
if (isAdmin && step === 4) {
stepFourSchema.parse(formData);
}
await api.post('/orders', formData);
alert('注文が完了しました!');
} catch (e) {
console.error(e);
alert('注文に失敗しました。');
}
};
return (
<div>
{step === 1 && (
<StepOne
data={formData}
setFormData={setFormData}
onNext={handleNextStep}
/>
)}
{step === 2 && (
<StepTwo
data={formData}
setFormData={setFormData}
onBack={() => setStep(1)}
onNext={handleNextStep}
/>
)}
{step === 3 && (
<StepThree
data={formData}
setFormData={setFormData}
onBack={() => setStep(2)}
onNext={handleNextStep}
showDiscountField={isSpecial}
/>
)}
{step === 4 && (
<StepFour
data={formData}
setFormData={setFormData}
onBack={() => setStep(3)}
onSubmit={handleSubmit}
/>
)}
{isSpecial && <NoteForSpecialOrder />}
{isAdmin && <NoteForSpecialOrder />}
</div>
);
}
☠️ さらに改修を続けていくと・・・
どんどん複雑化して、地獄の入り口に差し掛かる。
- 🤯 分岐数=画面の複雑性のリトマス試験紙
- 🔥 各ステップに if 条件が混在 → バグ修正時に見落としやすくなる
- 🤯 一つのコンポーネント内に複数の状態が現れだしたら黄色信号
- 🔥 StepOne 〜 StepFour が 1 ファイルにベタ書き → コンポーネント肥大化
- 🤯 状態とロジックがどんどん絡み出す
- 🔥 テストケースの爆発 → 特別注文、管理者注文、通常注文それぞれのテスト分岐地獄
つまり ──
「分岐するたびに死に近づいてる」
🧹 ここがリファクタのサイン
ここまできたら、
分岐を続けるんやなくて、コンポーネントを分けた方がええタイミングや。
✅ リファクタ戦略
ほんなら、リファクタしてみよか~。
この記事読んだあと、まるっと再現できるリポジトリも作っといたから、好きにいじり倒してな!
🏗️ 構造の見直し
まずは構造を見直そか。
今の OrderForm
、一人で背負いすぎやねん。
- フォームの状態
- 画面遷移
- バリデーション
- 特別注文か、管理者注文かの判定
── 全部 OrderForm
に押し込んでたら、そら破裂するわ。
せやからまず、注文タイプ単位で分ける。
features/
└── order-form/
├── components/
│ ├── AdminOrderForm.tsx
│ ├── SpecialOrderForm.tsx
│ ├── NormalOrderForm.tsx
│ └── MultiStepForm.tsx
├── model/
│ ├── schemas/
│ | ├── admin.ts
│ | ├── index.ts
│ | ├── normal.ts
│ | └── special.ts
│ ├── context/
│ | └── FormContextProvider.tsx
- 👉 各注文タイプごとにコンポーネントを分ける。
- 👉 スキーマもストーリーごとに切り出して独立。
- 👉 コンテキストも切って、どのステップからも formData にアクセスできるようにしとく。
💡 こうすることで、追加機能もバグも、タイプ単位で閉じ込められる。
💡 一箇所直したら全部バグるみたいな悪夢から抜け出せる。
切り出しておくことのメリット、たくさんあるなぁ!
詰め込み式のフラグ管理やめよ
ほんで、これ。
Before
<OrderForm isSpecial isAdmin />
注文のタイプに応じて、
内部で if (isSpecial) { ... } else if (isAdmin) { ... }
みたいな分岐してたやろ?
これ、実はな。
「未来の自分に地雷仕込んでる」 ようなもんやで。
後から踏み抜いて爆発するのは、間違いなく「君自身」や。
🚀 After
せやからこうする。
<AdminOrderForm />
<SpecialOrderForm />
<NormalOrderForm />
-
isSpecial
,isAdmin
とかいう props はもういらん - どういう注文なのか?は、ルーティングか親コンポーネントで決める
-
OrderForm
自体は、分岐せずシンプルなロジックだけ残る - テストも分けやすいし、Storybook も注文タイプごとに作れる
つまり、フォームコンポーネントは、そのストーリー専用にする。
✨ これでどうなる?
- 🔥 特別注文だけ変更 → 通常注文には一切影響なし
- 🔥 管理者注文だけ仕様追加 → 他のフローは触らんでええ
- 🔥 仕様変更に強い、テストもしやすい、コードも見やすい
- 🔥 未来の自分に感謝される
ホンマ、おおきにな。
分岐地獄を卒業するために、最初から「分けて」作ろ。
ほんで未来の自分に、爆速で「おおきに」言わせたれ。
せや、共通ロジックはまとめとこか
汎用的な実装を抽出すると、保守しやすなるで~。
どの注文タイプでも、ステップ管理はコレひとつで完結する。
これが「共通ロジック切り出し」の破壊力や。
// features/order-form/components/MultiStepForm.tsx
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui';
import type { StepProps, StepHandlers } from '../model/types/step';
type MultiStepFormProps = {
getSteps: (handlers: StepHandlers) => React.ReactElement<StepProps>[];
};
export function MultiStepForm({ getSteps }: MultiStepFormProps) {
const router = useRouter();
const [step, setStep] = useState(0);
const handleNext = () =>
setStep((step) => Math.min(step + 1, steps.length - 1));
const handleBack = () => setStep((step) => Math.max(step - 1, 0));
const handleSubmit = () => {
router.push('/thanks');
};
const steps = getSteps({
onNext: handleNext,
onBack: handleBack,
onSubmit: handleSubmit,
});
if (!steps.length) {
return (
<Card className='w-full max-w-xl bg-neutral-800 border border-white/20 shadow-md rounded-xl'>
<CardContent className='text-center text-white'>
No steps available
</CardContent>
</Card>
);
}
const CurrentStep = steps[step];
return (
<Card className='w-full max-w-xl bg-neutral-800 border border-white/20 shadow-md rounded-xl'>
<CardHeader>
<CardTitle className='text-white text-2xl'>
Step {step + 1} of {steps.length}
</CardTitle>
</CardHeader>
<CardContent className='space-y-6'>{CurrentStep}</CardContent>
</Card>
);
}
ほな、バリデーションを抽出
バリデーションがややこしいから、zod
使って、model に切り出してみるで~
バリデーションってロジックやから、UI コードとはちゃんと分けたほうがメンテしやすいんよな。
// features/order-form/model/schemas/normal.ts
import { z } from 'zod';
export const normalOrderSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().min(1, 'Email is required').email('Invalid email address'),
phone: z
.string()
.min(10, 'Phone number must be at least 10 digits')
.regex(/^\+?[0-9]{10,15}$/, 'Invalid phone number'),
orderId: z.number().positive('OrderId must be positive'),
});
export type NormalOrderSchemaType = z.infer<typeof normalOrderSchema>;
// features/order-form/model/schemas/special.ts
// 特別注文は通常注文に +α したやつやから、extend で再利用してるで
export const specialOrderSchema = normalOrderSchema.extend({
discountCode: z.number().optional(),
});
export type SpecialOrderSchemaType = z.infer<typeof specialOrderSchema>;
// features/order-form/model/schemas/admin.ts
export const adminOrderSchema = specialOrderSchema.extend({
remarks: z
.string()
.min(1, 'Remarks is required')
.max(500, 'Remarks cannot exceed 500 characters'),
});
export type AdminOrderSchemaType = z.infer<typeof adminOrderSchema>;
ほんで、FormProvider 作ろか
各 Step
で Form
の入力値を利用できるように、Provider
で注入してあげよ。
フォームデータって、ページ跨ぎやステップ遷移するから、ステートリフトアップ(親で持つ) するとバケツリレー地獄になるねん。
Context
で注入しといたら、どのコンポーネントからもサクッとデータ取れる。
つまり、バケツリレーを爆速で回避できる最強ムーブってわけやな。
そうすると、一元管理できて保守もしやすいで~。
// features/order-form/model/context/FormContextProvider.tsx
'use client';
import React, { createContext, useContext, useState } from 'react';
import { z } from 'zod';
import { adminOrderSchema } from '@/features/order-form/model/schemas/admin';
export type FormData = z.infer<typeof adminOrderSchema>;
type FormContextType = {
formData: FormData;
setFormData: (data: Partial<FormData>) => void;
resetForm: () => void;
};
const defaultFormData: FormData = {
name: '',
email: '',
phone: '',
orderId: 0,
discountCode: undefined,
remarks: '',
};
const FormContext = createContext<FormContextType | undefined>(undefined);
export function FormProvider({
children,
initialDiscountCode,
}: {
children: React.ReactNode;
initialDiscountCode?: number;
}) {
const [formData, setFormDataState] = useState<FormData>({
...defaultFormData,
...(initialDiscountCode ? { discountCode: initialDiscountCode } : {}),
});
const setFormData = (data: Partial<FormData>) =>
setFormDataState((prev) => ({ ...prev, ...data }));
const resetForm = () => setFormDataState(defaultFormData);
return (
<FormContext.Provider value={{ formData, setFormData, resetForm }}>
{children}
</FormContext.Provider>
);
}
export function useFormContext() {
const context = useContext(FormContext);
if (!context)
throw new Error('useFormContext must be used within a FormProvider');
return context;
}
ちなみに、FormProvider
で囲ってへんのに useFormContext()
叩いたら、即クラッシュするで!
こういうガードは最初から仕込んどくんが鉄板や。
そうすると
各フォームは steps
だけ指定すれば済む。
すっきりしててええ感じやなぁ~!
// features/order-form/components/NormalOrderForm.tsx
'use client';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { MultiStepForm } from './MultiStepForm';
import { getNormalSteps } from './steps/normal/getSteps';
import { normalOrderSchema } from '../model/schemas/normal';
import type { NormalOrderSchemaType } from '../model/schemas/normal';
export function NormalOrderForm() {
const methods = useForm<NormalOrderSchemaType>({
resolver: zodResolver(normalOrderSchema),
mode: 'onTouched',
defaultValues: {
name: '',
email: '',
phone: '',
orderId: 0,
},
});
return (
<FormProvider {...methods}>
<MultiStepForm getSteps={getNormalSteps} />
</FormProvider>
);
}
🛠️ さらにすっきりするで!
step を各シナリオ毎に分けると、さらにすっきりするし、追加改修があっても柔軟に対応できるで!
例えば:
- 特別注文だけ
Step3
に extra フィールド追加したい - 管理者注文だけ
Step2
に認証チェック入れたい
こういうとき、step 単位で分かれてるとそこのコードだけ見れば済む。
逆に、全部一緒くたになってたら:
if (isSpecial) { ... } else { ... }
if (isAdmin) { ... } else { ... }
みたいな分岐地獄がまた始まってまう。
→ せやから、step も分けるのが正義や。
ディレクトリ構成はこんな感じ
features/
└── order-form/
└── components/
└── steps
├── admin
├── normal
│ ├── getStep.tsx
│ ├── StepOne.tsx
│ ├── StepTwo.tsx
│ └── StepThree.tsx
├── special
├── guest/ # ゲスト注文用フォーム(未来)
└── enterprise/ # エンタープライズ注文用フォーム(未来)
未来にシナリオが増えても、この形なら耐えられる。
// features/order-form/components/steps/normal/getSteps.tsx
'use client';
import type { ReactElement } from 'react';
import { StepOne } from './StepOne';
import { StepTwo } from './StepTwo';
import { StepThree } from './StepThree';
import type { StepProps, StepHandlers } from '../../../model/types/step';
export function getNormalSteps(
handlers: StepHandlers
): ReactElement<StepProps>[] {
return [
<StepOne key='step1' onNext={handlers.onNext} />,
<StepTwo key='step2' onNext={handlers.onNext} onBack={handlers.onBack} />,
<StepThree
key='step3'
onBack={handlers.onBack}
onSubmit={handlers.onSubmit}
/>,
];
}
Special
/ Normal
も同じ要領で切り出していけるはずや!
getSteps パターンのうまみ
getSteps
をただの配列にせず、関数型にしてるのもミソや。
これのおかげで:
- 実行時にステップ構成を変えられる
- 必要なときだけステップを生成できる(Lazy 評価)
例えば、管理者だけ extra step 出したいときも:
getSteps({ isAdmin: true });
みたいに柔軟にできるわけやな。
利用側はシュッとこうなる
import { NormalOrderForm } from '@/features/order-form';
export default function NormalOrderPage() {
return <NormalOrderForm />;
}
import { SpecialOrderForm } from '@/features/order-form';
export default function SpecialOrderPage() {
return <SpecialOrderForm />;
}
import { AdminOrderForm } from '@/features/order-form';
export default function AdminOrderPage() {
return <AdminOrderForm />;
}
シュッてしててかっこええわ~、惚れてまうなぁ!
🎯 リファクタのメリット
- 🔥 注文タイプが増えても、他に波及しない
- → 未来で「法人注文」とか「ゲスト注文」とか増えても、「通常注文」が壊れへん。
- 🔥
Storybook
テストが圧倒的に楽になる- →
NormalOrderForm.stories.tsx
とかで フローごとに個別管理できる。
- →
- 🔥 運用フェーズで改修が怖くない
- → 「このボタン押したら他も壊れるかも……」みたいなビビり改修がなくなる。
- 🔥 パフォーマンスも地味に良くなる
- → 不要なロジック・ステート持たないから、コンポーネントのリレンダリングも減る。
- 🔥 オンボーディングが楽になる
- → 新人でも、
NormalOrderForm
だけ見ればその注文の全体像がわかる。読みやすい。
- → 新人でも、
📌 終わりに
技術的負債は “しゃあない” で生まれる。
けど、「未来の自分たちが耐えられる “しゃあない” かどうか?」を判断基準にすれば、致命傷にはならん。
フォームが複雑化しそうなとき、今日紹介したリファクタ戦略を思い出してほしい。
ほんのちょっとコンポーネントを分けるだけで、未来のバグと戦う時間は確実に減らせる。future-proof な実装になる。
未来の自分に 「おおきに」 言わせるリファクタ。
それができるエンジニアが、現場ではほんまに重宝されるねん。
AI 時代でも生き残るエンジニアっちゅうのは、そういう目の前の事を大切にできる人なんちゃうかな?知らんけど。
おまけ
ファイルの命名やけど、小さなパーツ(shadcn
)は kebab-case
、自作の画面コンポーネントはPascalCase
で統一するとスッキリするで!
今回の実装例は React
+ RHF
+ zod
やけど、Vue
+ VeeValidate
とかでも同じ地獄になるで!構造化が命や!
技術的負債について、他の記事もあるから読んでってな~!
👉 技術的負債ってなんやねん?編
👉 技術的負債の芽は「構造」にある編
Discussion