Sandi Maulana Juhana

Web Development

14 items

May 7, 2026 at 11:00 AM

When Every File Knows Its Job

On clean code and modular design — how I decide who is responsible for what, when to split a file, and when not to.

Og Image

Notes on Code That Holds Its Shape

There is a moment, usually somewhere in the middle of a project, when a file stops being easy to work in.

Not because it is broken. Not because the logic is wrong. Just because it has become too much.

You open it to fix one small thing and find yourself navigating through something much larger than the thing you came to fix. State, side effects, rendering, event handlers — all in the same place, because early in the project, that was the most convenient place to put them.

The file is not wrong. It is just no longer honest about what it does.

That is usually the right moment to start thinking about structure.

Not a rewrite. Not a migration plan. Just a quiet question: who is responsible for what?

1. One Responsibility, Clearly Named

A file that does more than one thing is not just harder to read. It is harder to trust.

When a component both manages state and renders UI, every change carries two kinds of risk. A logic change might break the layout. A layout change might disturb the logic. The coupling is invisible, but it is always there.

The practical rule I follow: a file should have a name that tells you exactly what it does. Not approximately. Exactly.

useWindowManager.ts — manages how windows open, close, minimize, and focus. FilterMenu.tsx — renders a dropdown filter with toggleable options.

When I can name a file that precisely, I trust what is inside it.

When I cannot, I start asking what it is doing that it should not be.

2. Shared Types Are Shared Language

One of the quieter problems in a growing codebase is duplicated structure.

Not duplicated code — duplicated intent. The same shape appearing in many places, each defined separately, each slightly at risk of drifting from the others over time.

A concrete example. Multiple components depend on the same set of base props. Define those props seven times across seven files and you have a definition you have to update seven times, in seven places, every time the interface changes.

A shared type solves this without abstraction overhead:

export type BaseWindowProps = {
  isOpen: boolean;
  onClose: () => void;
  onMinimize: () => void;
  onExpand: () => void;
  isExpanded?: boolean;
  zIndex?: number;
  cascadeIndex?: number;
  onFocus?: () => void;
};

Then each specific component intersects it:

type FinderWindowProps = BaseWindowProps & {
  items: FinderItem[];
  onOpenItem?: (item: FinderItem) => void;
};

The intersection tells the reader something immediately: these first eight props are a shared contract. Everything after the & belongs only to this component.

A shared type is not just a way to avoid repetition. It is a way to communicate intent. The reader does not have to guess which props are standard and which are specific — the structure makes it visible.

3. Icons Are Vocabulary

Inline SVG is easy to write once.

You paste the path, it works, the icon appears, you move on. But when the same icon needs to appear somewhere else — a sidebar, a notification badge, a tooltip, a search result — you are suddenly searching through files for a shape you know exists but cannot easily find.

You either copy the SVG again, or you trace it back to wherever you embedded it the first time.

The pattern that works is straightforward: one file, all icons, all exported by name.

export function BellIcon({ className }: { className?: string }) { ... }
export function LockIcon({ className }: { className?: string }) { ... }
export function VolumeIcon({ className }: { className?: string }) { ... }

Named, importable, reusable. An icon library that belongs to the project.

When an icon lives in a shared file, it has a name. When it has a name, it has meaning. When it has meaning, it can be used anywhere without hunting for the original SVG path.

Inline SVG is raw material. Named icon components are vocabulary.

4. Logic and UI Live in Different Places

The most useful separation in React is also the most overlooked.

State management and rendering are different jobs. They change for different reasons. Logic changes when the application's behavior changes. Rendering changes when the visual design changes. Keeping both in the same file means every change touches more surface area than it should.

The pattern is to extract the logic into a hook and leave the component as a pure renderer.

The hook answers one question: what is the current state of the application?

export function useOrchestrator(props) {
  // state, effects, callbacks, sub-hooks
  return { /* everything the renderer needs */ };
}

The component answers another: given this state, what does the screen look like?

export function Shell(props) {
  const state = useOrchestrator(props);
  return (
    <>
      <Header ... />
      <Sidebar ... />
      <Content ... />
    </>
  );
}

These are different questions. When they live in the same file, both answers are harder to find. When they are separated, each file can be read and changed independently.

A side benefit: logic in an isolated hook is easier to audit. Missing dependencies in a useCallback that hide in a 900-line file become obvious when the callback is in its own focused context.

5. Composing Instead of Accumulating

Extracting logic into a hook does not mean that hook should absorb everything.

A hook that grows by collecting every new concern will eventually hit the same problem as the monolithic component it replaced. It becomes too large, too coupled, too hard to navigate without reading everything.

The alternative is composition. A coordinating hook that delegates to focused sub-hooks, each owning its own domain.

useOrchestrator
├── useRegistry       (what is open, minimized, stacked)
├── useManager        (how things open, close, focus)
├── useContentLoader  (data, documents, routing)
├── useExternalData   (weather, calendar, third-party APIs)
└── ...

Each sub-hook owns its domain completely. The orchestrator composes them and surfaces a unified interface to the renderer above.

When a new concern appears — a new data source, a new window type — it gets its own hook. The orchestrator grows by connecting new things, not by absorbing them.

The result is a system that stays navigable. You always know where to look.

6. Long Is Not the Same as Wrong

There is a version of this thinking that goes too far: the idea that shorter is always better, that every long file is a problem waiting to be solved.

It is not.

Some files are legitimately long because their single responsibility is wide.

A file that collects every icon in a design system will be long. A component that renders a full-featured settings panel will be long. A terminal emulator with input handling, command history, and output formatting will be long.

Length is not the signal.

The signal is whether the file has one job or many.

The question I ask: can you describe what this file does in a single sentence? If yes — even if the file is 600 lines — it is doing its job. If no — even if the file is 80 lines — it is probably carrying something it should not be.

7. The One-Sentence Test

After a round of refactoring, I do a quiet pass through the codebase.

Not looking for problems. Just reading.

For each file, I ask: if someone opened this for the first time, could they say in one sentence what it does?

Some files answer immediately. Others take a moment.

The ones that take a moment are worth returning to. Not necessarily to split — sometimes they just need a clearer name, a better export structure, or one function moved to a more natural home. But the hesitation is worth noting.

A file that is hard to describe is usually doing something it should not be doing alone.

The audit does not have to be a formal process. It is just a question, asked regularly, as the codebase grows.

8. Modular Does Not Mean Many Files

The goal of modular design is not a large file count.

It is clear boundaries.

Inside a boundary, a file can be as long as it needs to be. Across that boundary, the interface should be as minimal as possible.

A project with thirty focused files is more modular than one with three hundred loosely-scoped ones. And when splitting a file would create more indirection than clarity — when the new boundary would feel arbitrary rather than natural — the right move is to leave it alone.

I keep this heuristic close:

Split when the separation reveals something. Stop when the separation hides something.

Closing Reflection

Clean code is not a style preference.

It is a commitment to making decisions visible.

The decision that this file handles state, not rendering. The decision that this icon belongs in a shared vocabulary rather than embedded in a single component. The decision that a shared type should exist, so seven files do not have to agree informally about a structure they all depend on.

These decisions do not have to be made all at once. They can be made quietly, one file at a time, as the codebase grows and its shape becomes clearer.

That is what the work actually looks like.

Not a dramatic rewrite. Not a migration plan. Just a steady attention to what each file is doing, and whether what it is doing is honest.