Recognizing Brittle Frontend Architecture
Most frontend codebases start life feeling clean. The components are small, the state is obvious, and adding a feature means writing new code rather than untangling old code. Then, gradually, something shifts. A file starts growing. An abstraction gets reused in ways it wasn't designed for. A bug in one feature breaks something that feels completely unrelated. Developers stop trusting the codebase and start writing defensive code around the parts they don't understand.
This is brittleness, and it rarely arrives all at once.
The tricky part is that brittle systems are not necessarily poorly written. Many of them are full of well-intentioned, carefully reasoned code. Brittleness is an emergent property — it's what happens when a series of individually reasonable decisions compound over time until the system resists change.
The most useful question you can ask about any design is not "is this well-written?" but rather: how resistant is this to change?
What Brittleness Actually Feels Like
Before getting into root causes, it's worth naming the feeling, because that's often what you notice first.
You open a file and feel a quiet dread before you've read a single line. You make a change that should be local and spend the next hour tracking down why something unrelated broke. A teammate asks you how a feature works and you realize you're not entirely sure, even though you wrote it.
The strongest single signal that a system has become brittle is when developers start being afraid to modify code.
When someone thinks "I don't know what this will break" before making a change, the architecture has already started failing them — not because the code is wrong, but because it's become too difficult to reason about. The fear is the warning sign.
Components with Too Many Responsibilities
The most common source of brittleness in frontend code is a component that has gradually accumulated too many jobs.
It usually starts small. You add a data fetch because it's convenient. You add a permission check because it's needed right here. You add a bit of transformation logic because the API doesn't return quite the shape you need. Six months later, the component is 400 lines long and understands your entire application.
// This component has quietly become load-bearing in the worst way
function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
// API transformation buried inside a component
const normalized = {
...data,
fullName: `${data.firstName} ${data.lastName}`,
memberSince: new Date(data.createdAt).getFullYear(),
};
setUser(normalized);
});
}, [userId]);
// Permission logic mixed into rendering
const canEdit = user?.role === 'admin' || user?.id === currentUser.id;
const canDelete = user?.role === 'admin' && user?.accountAge > 365;
if (!user) return <Spinner />;
return (
<div>
{/* 300 more lines of JSX, navigation, sibling coordination... */}
</div>
);
}graph TD D[UserDashboard] D --> A[Fetch API data] D --> B[Transform response] D --> C[Check permissions] D --> E[Handle navigation] D --> F[Render UI] D --> G[Coordinate siblings]
The problem isn't that any one of those things is wrong — it's that all of them are in the same place. You can't test the permission logic without rendering the component. You can't reuse the data transformation without importing the whole thing. You can't understand the rendering without reading past the fetch logic.
Separating responsibilities doesn't require a formal architecture pattern. It just requires asking: what is this component actually for?
// Each piece is now independently understandable and testable
function UserDashboard({ userId }: { userId: string }) {
const user = useUser(userId); // data access lives in a hook
const permissions = useUserPermissions(user); // domain logic is isolated
if (!user) return <Spinner />;
return <UserProfile user={user} permissions={permissions} />;
}The rendering component knows only how to render. The hook knows how to fetch and normalize. The permissions logic knows how to evaluate rules. None of them need to know what the others are doing.
Warning signs to watch for: components that are very long, components with a large number of props, deeply nested conditional rendering, or components that seem to coordinate unrelated things.
Unclear State Ownership
A large class of frontend bugs are really state ownership bugs in disguise.
When state is duplicated, or when its ownership is unclear, systems become brittle in a specific way: they develop synchronization problems. The UI shows one thing while the underlying data says another. A change in one place doesn't propagate to another. Developers spend more time tracing where the real value lives than solving the actual problem.
// Two components, each holding their own idea of the cart
function NavBar() {
const [cartCount, setCartCount] = useState(0);
// Updated on add, but is it in sync with CartPage?
}
function CartPage() {
const [cartItems, setCartItems] = useState<CartItem[]>([]);
// This is the "real" cart... probably
}This feels manageable at first. Then the badge in the nav shows the wrong count after a removal. Then someone adds a third place that needs cart data and the duplication gets worse.
The fix is not always "lift state to a global store." It's asking the right question: what is the lowest common ancestor of everything that truly depends on this state?
graph TD
subgraph before[Duplicated State]
N1[NavBar owns count]
C1[CartPage owns items]
N1 -. unsynchronised .-> C1
end
subgraph after[Single Source of Truth]
H[useCart hook]
H --> N2[NavBar]
H --> C2[CartPage]
end
// State owned once, accessed wherever it's needed
function useCart() {
const [items, setItems] = useState<CartItem[]>([]);
function addItem(item: CartItem) {
setItems(prev => [...prev, item]);
}
function removeItem(id: string) {
setItems(prev => prev.filter(i => i.id !== id));
}
return { items, count: items.length, addItem, removeItem };
}Now there is exactly one place that owns the cart. The nav badge, the cart page, and any future feature that needs it all read from the same source. When the state changes, everything that depends on it updates together.
Premature Abstraction
This one is counterintuitive: over-engineering often creates systems that are harder to change than if you'd just repeated yourself.
The instinct to abstract is healthy — duplication is a real maintenance cost. But abstracting before you truly understand the pattern means building a cage. You're forced to contort future requirements to fit an abstraction that was designed for different ones.
// Designed for every possible case before any real cases existed
function createFormField<T>(
type: FieldType,
validators: Validator<T>[],
transformer: (value: T) => string,
renderer: FieldRenderer<T>,
config: DeepPartial<FieldConfig<T>>
) {
// Nobody fully understands this anymore
}When a new field type doesn't quite fit the factory, developers do one of two things: they force it to fit (introducing accidental complexity) or they work around it (creating a parallel system). Either way, the abstraction becomes a liability.
The better approach is to let real duplication tell you when to abstract.
// Start with concrete implementations
function EmailField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return <input type="email" value={value} onChange={e => onChange(e.target.value)} />;
}
function PhoneField({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return <input type="tel" value={value} onChange={e => onChange(e.target.value)} />;
}
// Now the duplication is real and visible. The right abstraction becomes obvious.Once you have three or four genuinely similar things, the pattern is clear. You know exactly what varies and what stays the same, which means you can abstract without guessing. The principle worth internalizing: prefer duplication over the wrong abstraction.
Tight Coupling Between Features
Coupling happens when one feature depends on the internal details of another. It's often hard to spot because the code seems reasonable — you just need a utility from another module, or a constant that's defined somewhere nearby.
The problem appears when that other module changes.
// Feature A reaching into Feature B's internals
import { formatDisplayName } from '../auth/utils/nameFormatter';
import { PERMISSION_LEVELS } from '../auth/constants/roles';
import { useAuthStore } from '../auth/store/authStore';
import { UserRecord } from '../auth/types/internal';Every one of these imports is a dependency on auth's implementation details. If the auth team renames useAuthStore, restructures its types, or moves its constants, Feature A breaks — even though nothing about Feature A changed.
graph LR
subgraph Coupled
A1[Feature A]
A1 --> B1[auth/utils/nameFormatter]
A1 --> B2[auth/constants/roles]
A1 --> B3[auth/store/authStore]
A1 --> B4[auth/types/internal]
end
subgraph Decoupled
A2[Feature A]
A2 --> P[auth/index]
end
Healthy systems expose stable public interfaces and hide their internals.
// Feature A depends on auth's public contract, not its internals
import { useCurrentUser, hasPermission } from '../auth';
function SettingsPage() {
const user = useCurrentUser();
if (!hasPermission(user, 'edit:settings')) return <AccessDenied />;
return <SettingsForm user={user} />;
}The auth module controls what it exposes. Feature A only depends on that contract. If auth restructures itself internally, nothing outside it needs to change. This is what makes features independently evolvable.
Warning signs include: importing from deeply nested paths inside another feature, widespread references to the same internal utility, or large refactors that seem to touch dozens of unrelated files.
Hidden and Unpredictable Behavior
Technical correctness is not the same as legibility. A system can work perfectly and still be impossible to reason about.
The symptom is when developers can't answer basic questions: where does this data come from? What triggers this update? Why did this re-render?
One of the most common patterns that creates this kind of confusion is chained effects, where the output of one side effect feeds the input of another.
// What's happening here? Follow the chain.
useEffect(() => {
setVisible(isLoggedIn && !isBanned);
}, [isLoggedIn, isBanned]);
useEffect(() => {
if (visible) analytics.track('dashboard-viewed');
}, [visible]);
useEffect(() => {
if (visible) fetchDashboardData();
}, [visible]);Three effects, two layers of indirection, and you have to hold all of it in your head to understand when fetchDashboardData gets called. Add a few more of these and debugging becomes archaeology.
Preferring derivation over synchronization makes behavior explicit.
// The relationship is stated directly, not implied through a chain
const visible = isLoggedIn && !isBanned;
useEffect(() => {
if (!visible) return;
analytics.track('dashboard-viewed');
fetchDashboardData();
}, [visible]);The value of visible is computed, not synchronized. The effect depends on it directly. The behavior is traceable in one read.
More broadly: architectures that require developers to mentally simulate execution in order to understand behavior will be resisted. People naturally avoid modifying code they can't confidently reason about, and that fear compounds over time.
Brittle architecture is rarely the result of bad intentions. It's the accumulated result of small decisions made under pressure, incomplete information, and evolving requirements that no initial design could fully anticipate.
The goal isn't to build systems that never change. It's to build systems that remain understandable and adaptable as they do. Isolated responsibilities, clear state ownership, honest abstractions, and explicit behavior aren't sophisticated techniques — they're the conditions that let a codebase stay approachable long after the first commit.
The best architectures tend to be the ones where developers still feel comfortable making changes a year in.