From a 1,000-Line JavaScript Screen to Typed, Layered Sanity

🎯 Hook
Every codebase has a screen nobody wants to touch.
Ours was 1,000+ lines: layout, state, side-effects, and network calls all in one file. Change one thing, you had no idea what else moved. It shipped, it worked, and it got more expensive to change every single week.
So we rebuilt the foundation. Not the features — users saw nothing different. The inside. Same size, same behaviour, dramatically safer to touch.
Here's what changed and, more usefully, why.
🔥 Hot Take
💬 My take: Adding TypeScript to a messy JavaScript codebase is the developer equivalent of putting a seatbelt on a car with no steering. Types catch shape bugs — they don't stop your screens from calling the network directly, they don't isolate backend fields from your UI, and they don't break up god-screens. We considered doing exactly that (just adding TS, keeping the flat structure) and rejected it. It solves the smallest problem while leaving the one that actually rots the app completely intact.
📰 Deep Dive: The One Rule That Changed Everything
The entire rebuild hangs off a single constraint:
Dependencies flow one way. Each layer only knows the layer directly below it.
screens → hooks → services → api → domain types
That's it. Everything else — the TypeScript, the testability, the fact that a backend field rename now touches exactly one file — follows from enforcing this boundary in code, not in a wiki page.
The "before" in plain terms
- One
services/folder held HTTP code, React contexts, and 50+ custom hooks side by side - ~80 hand-rolled URL calls lived directly inside components
- Raw API JSON (snake_case, everything optional) was passed straight into the UI
- Hundreds of
any. The compiler couldn't help you. - The largest screen: 1,000+ lines mixing layout, state, effects, and fetching — unreviewable and untestable
None of this is unusual. It's what "move fast in JS" looks like after two years.
Why not just add TypeScript and stop?
We considered three paths:
| Option | What it solves | Why we rejected it |
|---|---|---|
| Add TS, keep flat structure | Shape bugs caught at compile time | Doesn't stop screens from calling the network. Coupling that rots the app stays intact. |
| Full Clean Architecture (use-cases, repositories, ports, interactors) | Maximum decoupling | Too much ceremony for a mobile app with a small team. An interactor per action, mostly pass-through. High friction, steep onboarding. |
| Pragmatic layered chain ✅ | Dependency rule + isolated translation seam | No ceremony. Services are plain classes. Hooks are the de-facto use-case layer. TanStack Query supplies the rest. |
The valuable core of Clean Architecture — the dependency rule and an isolated boundary where backend shapes meet your app — without the weight.
The five-step recipe for every endpoint
Any new backend integration follows the same steps, always in the same order:
1. DTO — the backend's shape, quarantined
// api/dto/OrderDTO.ts
export interface OrderDTO {
order_id: number;
total_amount: string; // backend sends money as a string
placed_at: string;
line_items: LineItemDTO[];
}
2. Mapper — one pure function, the only translation point
// api/mappers/orderMapper.ts
export function toOrder(dto: OrderDTO): Order {
return {
id: dto.order_id,
total: Number(dto.total_amount),
placedAt: new Date(dto.placed_at),
lineItems: dto.line_items.map(toLineItem),
};
}
When the backend renames total_amount → this is the only file that changes. That single property is most of the payoff.
3. Service — calls the client, returns a domain model (never a raw DTO)
// services/OrderService.ts
export class OrderService {
constructor(private api: ApiClient) {}
async getOrder(id: number): Promise<Order | null> {
const dto = await this.api.get<OrderDTO>(`/orders/${id}`);
return dto ? toOrder(dto) : null;
}
}
4. Hook — TanStack Query, two utilities that killed a category of copy-paste
// hooks/useOrders.ts
export function useOrder(id: number) {
const service = useDomainService(OrderService);
return useServiceQuery(service, ['orders', 'detail', id], s => s.getOrder(id));
}
useDomainService builds the service from the auth token + region in context, memoized, returns null before login. useServiceQuery wraps useQuery with enabled: !!service — the "don't run until authenticated" guard in one place instead of every hook. Across ~200 hooks, that matters.
5. Screen — layout only
function OrderScreen({ orderId }: { orderId: number }) {
const { data: order, isLoading, error } = useOrder(orderId);
if (isLoading) return <Spinner />;
if (error) return <ErrorState />;
return <OrderDetails order={order} />;
}
The screen has no idea the backend exists. Swap the API; this file doesn't move.
Mutations own their cache invalidation
A screen never refreshes data by hand:
export function useCancelOrder() {
const service = useDomainService(OrderService);
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => service!.cancelOrder(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['orders'] }),
});
}
🛠️ The practice I'd steal even if you skip everything else: flows.md
A layered structure tells you where code lives. It says nothing about what a screen is supposed to do. That knowledge used to live only in the implementation — one person's head.
Rule: before writing a screen, write a flows.md for it. Three parts:
- ERD — entities this screen touches
- Sequence — every interaction: user action → hook → service → API → state → navigate
- State machine — every distinct state, including
offline,reconnecting,backgrounded_mid_flow
<!-- screens/Checkout/flows.md -->
## States
- idle: cart shown, "Place order" enabled
- submitting: button spinner, inputs locked
- success: navigate away
- error: inline banner + retry, cart preserved
- offline: "Place order" disabled, offline notice
- backgrounded_mid_flow: re-check order status before re-enabling submit
Two outcomes: you catch missing states at design time (not as a production incident), and the states section is your test list — one test per state, one per transition. The doc that plans the screen also specs the tests.
💡 Dev Tip of the Week
Fix any at the source, not with a cast.
// ❌ This is a lie to the compiler
const user = data as User;
// ✅ Type the service return so the value is genuinely what you claim
async getUser(id: number): Promise<User | null> {
const dto = await this.api.get<UserDTO>(`/users/${id}`);
return dto ? toUser(dto) : null;
}
as User after response.json() doesn't validate — it silences. The compiler thinks it's safe; the runtime doesn't know or care. Fix the mapper, fix the service return type, and the value becomes genuinely typed all the way up.
🤔 Community Question
❓ When you inherit a messy JS codebase, what's your first move — add TypeScript first, enforce structure first, or do both together? And has anyone tried going incremental (one layer at a time) vs. the big-bang rewrite?
📌 What I'm Thinking About
The biggest open item after this rebuild: automated screen tests are still thin. The rebuild didn't add the risk — it was already there. But now the code has seams to test: logic in hooks, mapping in pure functions, flows pre-specced in flows.md. The next investment writes itself, starting from the highest-value flows. Starting there this sprint.