- Published on
The Rotting Ledger of Frontend Structure and State: Why "Where Does It Go" and "Who Owns It" Decide a Codebase's Lifespan
- Authors

- Name
- Jack Qin
There are two places in a frontend project that rot at a visible rate: the directory structure and the state management. They look like two separate things, but the mechanism of rot is identical — a boundary that was supposed to be held got quietly punched through during one "let's just keep it simple" moment.
This isn't meant to be a checklist of "how to arrange your folders." Anyone can copy a checklist, and the copy rots just the same. What I want to take apart is what class of bug each of these two boundaries is defending against, why things break when you don't hold them, and one easily-overlooked but directly data-leak-relevant advanced convention: clearing the server-side cache at the auth boundary. Once you understand the forces underneath, you can derive the convention yourself in a new situation instead of waiting for code review to catch you.
Directory rot: one "it's React code too" is all it takes
Directory rot usually starts with a sentence that sounds unarguable: "it's React code too, just drop it in src/components/."
The problem with that sentence is that it answers a question about ownership with an answer about file type. A table that only the weekly-reports page will ever use and a DataTable shared across three features are completely identical along the "is this React code" axis — but they are worlds apart along the "who owns it, who is affected when it changes" axis. Organizing your directories by file type means actively throwing away the information from that second axis. Six months later the outcome is certain: components/ is stuffed with things that actually belong to a single feature, and a newcomer trying to follow a piece of logic has to bounce between three directories.
So the first principle behind the convention isn't "feature-based folders look nicer." It's make the directory structure carry the ownership signal. The core rules in apps/web are just a handful, and every one of them serves ownership:
- Route entry points go in
src/app/. - Domain logic goes in
src/features/<feature>/. - Shared UI and cross-feature pieces go in
src/components/,src/contexts/,src/hooks/,src/lib/. - Prefer colocation; don't reflexively spin up a new top-level directory.
- Tests sit right next to the code they test.
Overall layout:
src/
├── app/ # Route groups and page entry points
│ ├── (admin)/
│ ├── (auth)/
│ ├── (others)/
│ └── (render)/
├── features/ # Feature-local api, hooks, components, types, config
├── components/ # Shared UI, layout, wrappers, cross-feature pieces
├── contexts/ # React Context providers for cross-cutting client state
├── hooks/ # Shared hooks reused across features
├── lib/ # API client, query client, auth, time zones, helpers
└── test/ # Shared test setup
The route groups are themselves a declaration of ownership:
(admin)— authenticated product pages(auth)— login, reset password, invite, callback, and other flows(others)— standalone pages like error and maintenance pages(render)— pages driven by a render token, used by automated rendering
A feature directory usually looks like this:
src/features/<feature>/
├── api/ # Request functions and query keys
├── hooks/ # React Query wrappers and feature logic
├── components/ # Feature-specific UI
├── types/ # Feature-local types
└── config/ # Feature constants when needed
The real test here is a single line: only when multiple features or routes need a piece of code do you promote it to a shared top-level directory. Put the other way: as long as a piece of code is used by exactly one feature, it should stay in that feature's directory — even if it "looks pretty generic." The cost of promotion is real: once something enters the shared layer, every change to it has to account for all potential consumers, and at that point there may not even be a second consumer.
The naming conventions serve the same goal — making ownership readable at a glance:
- Components use PascalCase:
DataTable.tsx,ProvidersWrapper.tsx. - Hooks use
use*:useEmailSchedules.ts,useCurrentUserQuery.ts. - Route files are usually
index.tsxunder a route directory. - Feature directories use kebab-case:
weekly-reports,email-schedules,user-management. - Use stable, descriptive names; avoid vague catch-all directories like
common2,helpers,misc— the namemiscis itself a confession that "I couldn't be bothered to think about who owns this."
State rot: copy the source of truth once, and now the two sides disagree forever
State rot is more dangerous than directory rot, because it directly produces data bugs. The classic anti-pattern: server data is already sitting in the React Query cache, and someone copies it into a Context too, on the grounds that "this makes it more global and convenient."
To see exactly what's wrong here, you first have to accept one fact: the same backend resource can have only one source of truth. The moment you copy it into a Context, you have two — one in the Query cache that updates on refetch, and one in the Context that's a snapshot from some particular moment. From the instant you copy it, the two go their separate ways: the cache invalidates, the Context doesn't know; a mutation succeeds and refreshes the cache, the Context is still stale. The "data out of sync" bug isn't an accident; it's the inevitable consequence of having two sources of truth.
So the essence of the state convention is to split responsibilities by "who owns the source of truth." The project uses a small set of tools, each with a clear boundary of responsibility:
| State category | Tool | Typical use |
|---|---|---|
| Local UI state | Component-local state | Toggles, table sorting, transient forms, interaction state |
| Cross-cutting client state | React Context | Auth/session, global filters, layout-level control |
| Server state | TanStack Query | API data, caching, refetching, mutation sync |
The project does not introduce a separate global client-state library for ordinary business flows — because most "global state" needs are, at bottom, server state. Its source of truth lives in the backend; the frontend should cache it, not re-host it.
- Local UI state: things that serve only the view itself stay local. For instance, when
DataTableisn't given external sorting, it maintains its sort state internally. - Context state: only when a client-side concern truly spans multiple routes or major layout regions do you promote it to a Context. For example,
AuthContextmanages auth/session state and the login/logout actions;ProvidersWrappercomposesAuthProvider,GlobalFiltersProvider, and the layout providers. - Server state: API data always goes through TanStack Query. The pattern is fixed — fetch through a feature hook, mutate through a feature hook, invalidate or refetch the corresponding query key after a successful write, and let the query's loading/error state drive the UI.
A detail worth copying: AuthContext depends on useCurrentUserQuery and does not maintain a second source of truth of its own. That one line of choice avoids the split of "I have a copy of the user data, and there's another in the Query cache." It never copies the user data into a useState, so the possibility of the two sides disagreeing simply doesn't exist — the bug wasn't fixed, it was designed out.
Advanced convention: why the auth boundary must clear the cache
There's one convention worth covering on its own, because what it defends against isn't "ugly code" but a class of data leak that has actually happened.
What to do: at every auth-boundary transition — login(), logout(), and the global 401 handler — remove all non-auth queries from the React Query cache before you populate the new user's data. The single exception is the auth-me / current-user key, because it is the controlled signal that drives isAuthenticated.
Why this is structural, not fastidiousness: the React Query cache lives by query key, and it has no idea which logged-in user this data belongs to. The marker-point queries, site lists, and accessible-site queries in the cache are all data from the previous session, scoped to the previous user. If those caches are still alive when you switch users, they will render on the very first frame after the new user logs in. On a shared mobile device — a cab tablet, multiple drivers rotating through a single shift — this leak window is real: user A's data shows up on user B's screen. There's a full layer of gap between the cache's "lives by key" semantics and the business semantics of "data belongs to a user," and clearing the cache is how you explicitly close that gap.
function clearAuthenticatedQueryData(queryClient: QueryClient): void {
for (const query of queryClient.getQueryCache().findAll()) {
if (!isCurrentUserQueryKey(query.queryKey)) {
queryClient.removeQueries({ queryKey: query.queryKey, exact: true })
}
}
queryClient.setQueryData<CurrentUserDto | null>(AUTH_ME_QUERY_KEY, null)
}
// login(): clear before populating the new user
clearAuthenticatedQueryDataBeforeLogin(queryClient)
await authClient.login(email, password)
const user = await queryClient.fetchQuery({ ...currentUserQueryOptions, staleTime: 0 })
// logout() / 401 handler: clear and reset the auth key to null
clearAuthenticatedQueryData(queryClient)
This convention has an easily-missed directional aspect: it must apply both ways. The web's ProvidersWrapper already installs a 401 handler that clears query state and redirects — same convention, just in a different transport shell. When you introduce any new auth provider, clear in both directions: login and logout/401. Clearing only on login and not on logout leaves the leak window wide open; you've just moved the trigger.
A few counter-examples, and the flaw they share
// Counter-example 1: putting a feature-only component in a shared directory just because it "is React code"
// src/components/WeeklyReportTable.tsx ❌ only weekly-reports uses it
// Correct: put it under the feature directory
// src/features/weekly-reports/components/WeeklyReportTable.tsx ✅
// Counter-example 2: copying server state into a Context to make it "feel more global"
const AuthProvider = ({ children }) => {
const { data } = useCurrentUserQuery()
const [user, setUser] = useState(data) // ❌ second source of truth, will drift from the Query cache
// ...
}
// Correct: consume the Query result directly, don't keep a second copy
const AuthProvider = ({ children }) => {
const { data: user } = useCurrentUserQuery() // ✅ single source of truth
// ...
}
// Counter-example 3: fetching directly in a page component, bypassing the existing feature-hook pattern
function HeatmapPage() {
const [data, setData] = useState()
useEffect(() => {
fetch('/api/v1/heatmap')
.then((r) => r.json())
.then(setData) // ❌
}, [])
}
// Correct: push the logic down into a feature hook, keep the route file thin
function HeatmapPage() {
const { data } = useHeatmapQueries() // ✅
}
Stack the three counter-examples together and the flaw is the same: each bypasses a boundary that was supposed to be held — counter-example 1 bypasses the ownership boundary, counter-example 2 manufactures a second source of truth, counter-example 3 bypasses the feature-hook data path. The other things to avoid are all in the same family: don't temporarily stash a feature-specific hook or component in src/components/; don't introduce a new top-level architectural pattern when an existing feature directory is already a fit; don't copy server-state logic into route files; don't maintain a parallel cache for the same backend resource outside TanStack Query.
Putting it into practice
- Ask about ownership before creating a file: is this code used by only one page? Colocate it. Used by multiple features? Then consider promoting it.
- Server data goes into React Query by default, unless there's a strong reason to stage or transform it locally.
- Context is a scarce resource, reserved for concerns that genuinely span multiple routes: auth, global filters, layout-level control.
- Clearing the cache at the auth boundary goes both ways: when you add any auth provider, clear on both login and logout/401.
- In code review, watch directory ownership and the source of truth: does new code follow the feature-based structure? Do API calls go through the shared client and feature hooks? Is anyone manufacturing a parallel cache for the same data?
The transferable layer
Strip away the specific APIs of React and TanStack Query, and the genuinely transferable insight behind both conventions is one sentence: rot always happens where a boundary gets silently punched through, and what punches it through is usually a "let's just keep it simple" that sounds unarguable. "It's React code too," "copy it into Context to make it more global," "fetching directly in the page is faster" — each sounds valid on its own; what's wrong is that they all cross a boundary nobody re-examined.
Faced with a "where does it go / who owns it" decision, instead of asking "is this convenient to write," flip it: who owns this code? Where is the single source of truth for this data? Am I about to manufacture a second one? A codebase where a newcomer can guess "where does the code go" and "who owns the state" without asking isn't one with a lot of written conventions — it's one where both questions have a single answer everywhere.