Hey folks!
Lately I spend more time talking to AI than to humans — but no matter who I talk to, code always ends up here:
Technical debt.
Anyway, I’m @nyaomaru, a frontend engineer.
In my previous article, I explained what technical debt actually is.
This time, let’s see how it quietly piles up — using practical, code-based examples.
I tried to recreate scenarios that feel painfully real:
code that starts clean, but slowly turns into a nightmare as new requirements keep coming in.
Ready? Let’s dive in! 🚀
💸 How Debt Quietly Builds Up
Nobody sets out to write dirty code.
But those small “just for now” compromises? They silently sabotage the future.
Even with reviews, Copilot, or AI tools like Code Rabbit catching obvious mistakes,
the real danger isn’t in single lines of code — it’s in feature creep and rushed decisions under deadlines.
Debt grows through things like:
- The system expanding in scale
- Patches stacking up with each new feature
- Old code being misunderstood
- Abstractions never happening
Each one seems harmless, but together they multiply.
For this article, let’s zoom in on:
👉 When overlapping modifications pile up
(using React
with Next.js
, zod
, RHF
, and shadcn
).
🔧 The “Just One More Change” Spiral That Breaks the Future
When you’re building a form UI, this happens a lot, right?
“Let’s make it a multi-step form.”
So far so good.
But then the requests keep coming.
Every time a new requirement lands, you keep escaping with “let’s just make it work for now.”
Let’s imagine the aftermath of repeatedly shoving conditions into existing code and peppering in workarounds.
First up
We built a normal order form.
It’s still simple and readable.
At this point, it’s not even worth refactoring yet — maybe if there’s spare time.
features/
└── order-form/
├── components/
│ └── OrderForm.tsx
🚨 Initial state (normal order only)
// Minimal OrderForm example
import { useState } from 'react';
type Form = {
customerName: string; // Step1
email: string; // Step1
orderId: number; // Step2
phone: string; // Step2
};
// Order form
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('Order completed!');
} catch (error) {
console.error(error);
alert('Order failed.');
}
};
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>
);
}
Okay, let’s add a feature
Next, we need to support special orders.
We’ll also add validation so weird values can’t slip through.
This is where the once-simple code starts getting noisy.
🔥 After multiple patches (special order added)
// After adding "special order"
import { useState, useEffect } from 'react';
import { stepOneSchema, stepTwoSchema, stepThreeSchema } from './formSchema';
type Form = {
customerName: string; // Step1
email: string; // Step1
phone: string; // Step2
orderId: number; // Moved from Step2 to Step3
discountCode?: number; // Step3 (only for 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-case behavior
useEffect(() => {
if (isSpecial) {
// Initialize discountCode only for special orders
setFormData((prev) => ({
...prev,
discountCode: 666, // temporary default
}));
}
}, [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('Order completed!');
} catch (e) {
console.error(e);
alert('Order failed.');
}
};
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>
);
}
😇 And then…
On top of that, we’re told to support admin orders.
As features keep stacking, it becomes more than “hard to read” — it becomes soul-crushing to maintain.
The worst part? A forest of if branches blooming inside the component.
Behold:
// After adding "admin order" too
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; // Moved from Step2 to Step3
discountCode?: number; // Step3 (only for special)
remarks?: string; // Step4 (only for 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) {
// Initialize discountCode for special orders
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 users go to Step4; others submit
if (isAdmin) {
setStep(4);
} else {
await handleSubmit();
}
}
} catch (e) {
console.error(e);
}
};
const handleSubmit = async () => {
try {
// If Step4 exists, validate it as well
if (isAdmin && step === 4) {
stepFourSchema.parse(formData);
}
await api.post('/orders', formData);
alert('Order completed!');
} catch (e) {
console.error(e);
alert('Order failed.');
}
};
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>
);
}
☠️ Keep patching and you reach the gates of hell
Things spiral:
- 🤯 Branch count is the litmus test for screen complexity
- 🔥
if
logic sprinkled across steps → easy to miss cases during bug fixes
- 🔥
- 🤯 Multiple states living inside one component = yellow flag
- 🔥 StepOne–StepFour crammed into one file → component bloat
- 🤯 State and logic entangle
- 🔥 Test cases explode → special/admin/normal all branch differently
In short:
Every new branch is one step closer to disaster.
That’s your signal.
🧹 This is your refactor sign
When you get here, don’t keep branching.
It’s time to split components.
✅ Refactor Strategy
Let’s refactor. I also prepared a fully reproducible repo so you can play with it after reading. (Please don’t actually blow it up 😂)
https://github.com/nyaomaru/technical-debt-sample
🏗️ Rethink the Structure
Right now, the single OrderForm
is carrying way too much:
- Form state
- Which flow is active
- Screen transitions
- Validation
- Deciding whether it’s a special/admin order
Shoving all of that into one component is a recipe for explosion.
So first, split by order type.
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
👉 Separate components per order type.
👉 Extract schemas per story so they’re independent.
👉 Provide context so any step can access formData
.
💡 This boxes both features and bugs by type.
💡 No more “fix one place and break everything” nightmares.
Tons of upside just by cutting things apart!
Drop the All-in-One Flag Management
Before
<OrderForm isSpecial isAdmin />
Inside, you branch on:
if (isSpecial) { ... } else if (isAdmin) { ... }
This is basically:
planting landmines for your future self.
You know who steps on them later? You.
🚀 After
So do this instead:
<AdminOrderForm />
<SpecialOrderForm />
<NormalOrderForm />
- No more
isSpecial/isAdmin
props - Decide the order type via routing or the parent
- Keep each form simple, without internal branching
- Tests and Storybook become per-type and much cleaner
In short, make each form component story-specific.
✨ What You Get
- 🔥 Change “special order” only → “normal order” stays untouched
- 🔥 Add admin-only behavior → other flows remain safe
- 🔥 Strong against spec churn, easier tests, cleaner code
- 🔥 Future-you says thanks
Graduate from branch hell by splitting from the start.
Make future-you say “much obliged” at warp speed.
Centralize Shared Logic
Extract common bits first.
The step orchestration should be handled by one place regardless of type.
That’s the power of extracting shared logic:
// 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 } from '../model/types/step';
type StepHandlers = {
onNext: () => void;
onBack: () => void;
onSubmit: () => void;
};
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>
);
}
Extract Validation
Validation is logic, so keep it out of UI. Use Zod and move it under model/
.
// 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(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number (E.164 format)'),
orderId: z.number().positive('OrderId must be positive'),
});
export type NormalOrderSchemaType = z.infer<typeof normalOrderSchema>;
// features/order-form/model/schemas/special.ts
// Special is Normal + alpha, so reuse via 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>;
Build a FormProvider
Form data crosses pages/steps, so lifting state to a parent quickly becomes a bucket brigade.
Inject via Context so any step can access it easily — the fastest way to dodge that brigade.
// 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;
}
If you call useFormContext()
without the provider, it crashes immediately — that guard is intentional. Ship with it from day one.
Then Each Form Only Wires Its Steps
Clean and readable:
// 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>
);
}
🛠️ Make It Even Cleaner
Split steps per scenario so you can tweak each in isolation.
- Special order only: add extra field on Step 3
- Admin only: add an auth check on Step 2
When steps live separately, you only read the code for that step.
If everything is mashed together, you’re back to:
if (isSpecial) { ... } else { ... }
if (isAdmin) { ... } else { ... }
…aka branching hell. Split the steps.
Suggested Directory for Steps
features/
└── order-form/
└── components/
└── steps
├── admin
├── normal
│ ├── getSteps.tsx
│ ├── StepOne.tsx
│ ├── StepTwo.tsx
│ └── StepThree.tsx
├── special
├── guest/ # future guest flow
└── enterprise/ # future enterprise flow
Future scenarios? This structure scales.
// 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 } from '../../../model/types/step';
type Handlers = {
onNext: () => void;
onBack: () => void;
onSubmit: () => void;
};
export function getNormalSteps(handlers: Handlers): 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}
/>,
];
}
Do the same for Special/Admin.
Why a getSteps
Function Instead of a Plain Array?
Because:
- You can build the step list at runtime
- You only create steps when needed (lazy)
E.g., show an extra admin step:
getSteps({ isAdmin: true });
Usage Reads Nice and Flat
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 />;
}
Chef’s kiss.
🎯 Benefits of This Refactor
- 🔥 Adding order types doesn’t ripple through others
- → Future “enterprise” or “guest” flows won’t break “normal”.
- 🔥 Storybook/tests become dramatically easier
- → Manage each flow independently (e.g., NormalOrderForm.stories.tsx).
- 🔥 Onboarding is smoother
- → New devs learn a flow by reading one folder.
- 🔥 Ops-phase changes feel safe
- → No more “touch this button and everything explodes” anxiety.
- 🔥 Small perf win
- → Less unnecessary state/logic = fewer re-renders.
🔧 Bonus: Tiny Naming Tip
I keep third-party UI atoms (e.g. shadcn/ui
) in lowercase and my own screen components in PascalCase.
It makes grep/search and reviews a bit saner.
✂️ That’s the gist!
Technical debt doesn’t explode overnight — it creeps in through shortcuts and deadline-driven choices.
We walked through how a simple form turns into a branching nightmare when “just one more feature” keeps getting added.
The fix? Split responsibilities early, refactor when branches multiply, and keep each flow isolated.
That way, future you will say “thanks” instead of “why did past me hate me?”. 🙃
Have you seen similar debt creep in your own forms or features?
Drop a comment, or share how your team keeps it under control — I’d love to hear your stories!
Top comments (0)