DEV Community

nyaomaru
nyaomaru

Posted on

Technical Debt Grows from “Just for Now” — A Real-World Code Walkthrough

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
Enter fullscreen mode Exit fullscreen mode

🚨 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

😇 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

☠️ 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
Enter fullscreen mode Exit fullscreen mode

👉 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 />
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode
  • 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode
// 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>;
Enter fullscreen mode Exit fullscreen mode
// 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>;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

🛠️ 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
Enter fullscreen mode Exit fullscreen mode

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}
    />,
  ];
}
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

Usage Reads Nice and Flat

import { NormalOrderForm } from '@/features/order-form';

export default function NormalOrderPage() {
  return <NormalOrderForm />;
}
Enter fullscreen mode Exit fullscreen mode
import { SpecialOrderForm } from '@/features/order-form';

export default function SpecialOrderPage() {
  return <SpecialOrderForm />;
}
Enter fullscreen mode Exit fullscreen mode
import { AdminOrderForm } from '@/features/order-form';

export default function AdminOrderPage() {
  return <AdminOrderForm />;
}
Enter fullscreen mode Exit fullscreen mode

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)