Colocation is king
Lately I’ve been thinking about modern clean code and about what makes a codebase resilient over time. I’m trying to capture some of the learnings from working on codebases that change often and in unpredictable ways.
Colocation or the proximity principle ensures that related code components are grouped together. As an example, a piece of code that describes a UI element should be in close proximity with the logic for how that element works. In the past colocation has been seen as the oposite of separation of concerns. But this was proven wrong with the rise of frameworks and libraries that encourage the colocation of technologies.
Optimize for writing vs reading
Before we talk about colocation, it’s important to distringuish between the “two phases” for a piece of code. The creation phase is when you write the code and the consumption phase is when you (or others) are reading it. When it comes to resilience, the reading phase carries more weight. This is because the amount of time spent reading the code is significantly higher than the amount spent writing it.
However, the importance of a good developer experience (DX) when writing code should not be underestimated. The success of technologies adopted by the mainstream community is owed to good DX for writing code. If you think about it, the experience of writing is what drives the hype up. Fewer thoughts are given to the reading phase and to long term resilience or maintainability.
Colocation as a vector for adoption
But it’s not just hype. A good DX for writing ends up shaping the languages and tools we use over time. Let’s look at how frontend tools and frameworks evolved over the past years.
JSX
React took the world by storm in 2014. But setting aside everything else that made the framework a huge success, I’d argue JSX was the primary driver for adoption. All of a sudden, focus was not distributed between writing a template file and writing the logic to connect to that template. And people loved that. JSX brought all HTML elements in JavaScript, so now the visual order of elements and the associated logic could be expressed in one writing.
Has React convincingly won the frameworks war? No, we still have a large variety of frameworks out there. But has JSX become one of the main languages for writing frontend? Yes. JSX offers such a good experience that a vast majority of modern frameworks adopted it. Colocation FTW.
CSS in JS
Going back to JSX, writing UI is still fragmented by the need to switch between the component and the styles. CSS in JS solves that by colocating the missing piece. Now you describe: the semantics, the logic and the visual properties in a single file.
Colocation was complete with the introduction and adoption of the css prop. Instead of writing styles in a separate block next to the component, they are written next to the JSX nodes. As an analogy, this is similar to hand writing, where you don’t lift your pen from the paper as you write an entire paragraph in one go.
return (
<Box css={{
padding: "1rem",
border: "1px solid lightgrey",
"&:hover": {
background: "lightgrey"
}
}}>
<Text>{title}</Text>
<!-- ... -->
</Box>
)
A quick note here: The css prop has its drawbacks and there are always trade-offs to consider with CSS in JS libraries. Still, this pattern is used in combination with design systems and is supported by most styling tools.
Tailwind
Finally, colocation is what made Tailwind so powerful and loved. Developers are reporting a higher level of satisfaction than with any other framework. And while I have my reservations around maintainability, Tailwind does offer one of the best experiences for writing frontend code.
Colocation as a vector for maintainability
So colocation has been an important step in the evolution of tools and frameworks. What about long term maintainability? How does colocation help with that?
Components, subcomponents, utilities
As a project grows, components will start poping up in folders and sub-folders. So you come up with a project structure. Some components will be broken down into subcomponents and they will have their own utility functions. You will be tempted to keep things neat and organized, but as the project gets bigger, that original structure needs to adapt as well, in order to scale.
If you follow the conventional “one component per file”, you risk creating a lot of files that put even more presure on that structure. I prefer a more relaxed approach. Each file should have a single exported component. But it can have subcomponents, helper functions, or anything that breaks down the main component.
You can think of it as deferring the moment when a component or a utility gets a file. There are examples of such files in the codesandbox-client codebase here and here. If one of those subcomponents becomes heavier, requires separate testing or can be reused, then it’s time to extract it.
This is of course a form of colocation. And, in my experience, it reduces the amount of dead code over time. When separated, those small components or utilities are the first to become unused, as the main component changes.
This pattern also helps people that haven’t written the code, or have limited knowledge about the implementation. All the changes they have to make are in a single file and they know that changes are not accidentally propagated to other areas of the application.
Components and async data sources
One of the common challenges frontend developers face is integrating async data sources into their UI. Meta frameworks like next.js or remix introduced built-in support for data dependencies for pages.
It’s also worth mentioning that the new paradigm of React Server Components (RSCs) encourages colocation. RSCs allow you to write async components that run on the server and include inline data fetching and processing.
export default async function Page() {
const templates = await fetchTemplates();
return (
<PageWrapper>
<PageTitle title="Select a template."/>
<TemplateGrid templates={templates} showFilters />
</PageWrapper>
);
}
Finally, one of my favorite examples of colocation is the use of data fetching libraries like SWR or TanStack Query. Both are super useful when you want to avoid caching all the data in your state object.
These are all scenarios where colocation helps long term maintainability. It is very common for data models to change together with UI in case of: new features, new use cases or changes to existing flows. So any pattern that keeps the async data close to the UI will help with the cognitive load of navigating the codebase.
A case against reusability?
You might think all these suggestions will end up duplicating code and limiting reusability. And while this might be true, it is important to be critical about reusability. So in essence, don’t reuse something just for the sake of reusability or because it is the expected way of writing code.
Reusability is a double edged sword, it can create artificial abstractions and bloated APIs. A fine balance is required to make sure it does not create unwanted complexity and confusion for someone reading the code later.
As a pragmatic product-focused developer, my rule of thumb is that the patterns should not stand in the way of development. You should not be forced to go through layers after layers of abstraction to add a new feature. Similarly, you shouldn’t have to pause and think about where the best location for a piece of code is. Reusability and abstraction can come later, when that piece of code is proven as a long term solution.