Locality of Behavior in React Components

May 01, 2024 4 minute read

You don’t need design patterns or principles to build software. You can write everything in a single file that executes from top to bottom and I’ll sure you’ll be able to make it work.

But why do we consider this a bad practice?

Because it’s not maintainable. There’s nothing wrong with such an implementation - it will produce the proper result. It will return the correct data and display the right elements on the screen.

But if you have to change its output or make tiny changes to it over time, you will find that the “big ball of mud” approach to software engineering fails.

While programming is solving a problem through code, engineering is maintaining that solution over time. After building software for the last 10 years, I’ve found the former is easy, but the latter isn’t.

Locality of behavior is one of the things that makes maintaining software easier.

The larger part of an implementation you can understand by looking at it, without jumping from one file to another, the better. It’s very hard to keep the context of a large project in your head.

When you need to make a change to it, having everything you need in front of your eyes helps a lot.

Locality in a React Component

I repeat the mantra that every React component should be self-contained. It should own its markup, styles and functionality and isolate them from the rest of the application. When we’re making changes to a component we shouldn’t be able to make decisions based entirely on its contents.

function ItemDetails() {
  const { item, onClick } = useItemDetails()

  if (!item) {
    return (
      <div className="text-center text-gray-500">
        No item to display.
      </div>
    )
  }

  return (
    <div className="max-w-md mx-auto mt-10">
      <div className="bg-white shadow px-6 py-4 rounded-lg">
        <h2 className="text-lg text-gray-700">{item.name}</h2>
        <p className="text-gray-600">{item.description}</p>
        <button
          onClick={onClick}
          className="mt-4 bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
        >
          Delete Item
        </button>
      </div>
    </div>
  )
}

export default ItemDetail

This is a simple self-contained component. It makes a decision about what markup to render based on data and calls a function when the delete button is clicked. Everything you have to know about its functionality and styles is here.

Styling changes are the ones we do most frequently. Finding a way to keep visualization logic inside the component is a big step towards its maintainability, regardless if its through Tailwind, CSS-in-JS libraries or another tool.

By maintaining locality of behavior, we don’t have to jump between files, making the context we need to keep in our heads smaller, and reducing the chance of making a mistake.

But the keen-eyed will immediately notice a paradox in our implementation. We’re inlining our styles, but we’re not inlining our domain logic.

Why?

Inlining and Invocation

There’s an important difference between inlining and invocation.

Keeping all our code inlined would make it too verbose. And since it’s not the component’s job to know where the data it uses is coming from, we’ve created an abstraction in the form of a custom hook. Even though this seems the opposite of what we’re talking about, abstractions are not an enemy of locality.

You don’t need to inline everything in a single file.

We’d be degrading our maintainability if we did that, and we’d be back right at the beginning of this article, writing everything in a single file.

In the context of a React component, the invokation of the function is more important than what it actually does.

The only criticism that we have toward the initial implementation of the component is that its function’s name is too generic for our needs.

Naming Event Handlers

I consider event handlers to be part of the domain logic in a React application (you can read more in this article). The component should be responsible for when and where it’s invoking the event, but it’s the domain layer that should decide what to do when it happens.

This means that our domain layer shouldn’t be giving us functions related to event handlers.

It should provide us with descriptive functions named after actions that we can then decide where to attach in the component, improving the locality of the code.

function ItemDetail() {
  const { item, deleteItem } = useItemDetails()

  // ...
}

We have the markup, the styles, and the invocations of the domain-specific methods - all in one place.

Classes are an Abstraction

The Tailwind classes we’re using are still an abstraction, they’re not inline styles. They are the CSS equivalent of the descriptive function we just mentioned. So even though it looks like we’re inlining styles and abstracting away domain logic, we’re abstracting both.

The reason I’m using Tailwind utility classes instead of semantic classes is that the former provides us with much better maintainability. If you want to read the long explanation, you can do so in this article.

Locality is Maintainable

Locality of behavior is a practice I first heard about in one of htmx’s essays. Things that change together should live together. The markup, the styles, and the places where we invoke certain methods will be the things that will affect each other when they change.

If we have to redesign a page, we will be changing the color identity of the components on it and their placement. But the logic that sends a request to delete an item would most likely remain the same. There’s no point in inlining it because APIs getting deprecated don’t corelate with UI changes.

Ask yourself this.

What in this code changes together and how can I make it obvious?

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.