
Creating comprehensible frontends
The obsession to understand and articulate sane ways to build highly dynamic front-end apps has once again piqued my interest. I obsess because it can feel so chaotic at times. While nuanced and complex product requirements are something we strive to fulfill, it sure seems like we struggle to do so gracefully. We live in a world of state, re-renders, network requests, recurring timers, manual cleanup of these timers, etc., and the crazy part is that most front-end teams have loose rules for where things go. They often obsess over the folder structure (put another way, figuring out how to decompose a project at a high level), but don’t create categories to decompose the flow of logic and side effects. Meanwhile, our back-end counterparts have well-defined categories such as controllers, services, repositories, mappers, clients, etc.
The topic of this reflection is whether or not it has to be this way, and if it doesn’t, what it could look like.
To establish a starting point for this reflection, I assert simple, non-exhaustive criteria for gauging the comprehensibility of a stateful front end:
-
How long does it take someone other than the author to exhaustively identify actions that can be taken by a user just by looking at the code itself?
-
Can someone other than the author reasonably understand a user’s action from a single starting point in the code without needing to jump to another call stack.
-
How easy are unit tests to write and understand? Does making them work feel like a magical incantation or is it straight forward to write and understand?
I choose these criteria because I believe they capture the essence of comprehending a front end. If these criteria are met to a high standard, things like code review and getting a team up to speed on complex code should be much easier.
As an example for criterion 1, let’s suppose someone opens a pull request to add functionality to an existing feature. As a reviewer, I want to know: what causes the new functionality to be invoked. In other words, what actions must a user of the app take in order to trigger the changed code? If answering this question is hard, then the code isn’t that good. If there isn’t an opinionated way to handle new user actions, then the code likewise isn’t that good. To take an example from our back-end colleagues, I generally know exactly where endpoints are defined in their code—do we know where our user actions are defined?
For criterion 2, we continue the previous example. We’ve identified the action taken by a user to invoke new behavior in our PR, and now must understand what happens as a result of the action. The best-case scenario is that I can read the code starting at the action and go line by line to understand how new logic impacts state, then clearly understand how the component’s template subscribes to the impacted state. The antithesis of this experience is trying to understand how several useEffects (or your framework’s equivalent) watch state changes to invoke functions. Even a small number of state changes and associated conditionals in these useEffects can make the front-end system difficult to comprehend. Subscribing to state changes in useEffect can sometimes feel like out-of-control goto statements on steroids—it’s the fastest way for an app to become unmaintainable.
For criterion 3, the unit tests ideally prove in simple terms that the component works as intended. Simple terms is the key metric here. If mocking out the requirements for a unit test is painful to both write and read, then it might be time to rethink the way the code is written.
How do we achieve comprehensibility to this standard? Is it even possible to improve on our current situation where components are allowed to house anything and everything—from network requests to logic, to action definitions, to timers, etc.? The answer is an obvious yes. If you’ve read my posts before, you’ll know that I generally point to two implementations that have solved this problem: The Elm Architecture and Redux. For our conversation, let’s just stick with Redux.
From my experience, Redux is grossly misunderstood. The real tragedy of Redux is that front-end engineers began using the tool without understanding how to use it. We quickly began using it for handling network requests and for sharing state among components. I argue that we used an opinionated tool that adds heavy structure to our app to solve simple problems like these—only to become jaded with the tool when we finally realized how painful it was to use in the (wrong) way we were using it.
As an aside, you should read the Redux Style Guide if you haven’t already.
Redux was not created as a tool to handle just your network requests and a limited amount of global state. Its real purpose is to solve the problems laid out in this discussion.
Just think about it.
What if everything that could happen in a feature you were building was clearly enumerated? In Redux, these are called actions.
What if every piece of logic that came about as a result of these actions had an opinionated place to live? In Redux, this happens in your reducer, not your component.
What if we carved out special space to isolate side effects because they’re painful to deal with? In Redux, these are thunks.
What if the impact of state updates on our template had a clearly defined space that could be tested and reasoned about separately from the template? These are selectors.
My argument isn’t that your app should use Redux, but we sure have a lot to learn from Redux when we understand its intended use. If we’re not using Redux to create categories for different kinds of operations that happen in our apps, then what are we using instead?
Redux aside, we can still accomplish much of the discussed structure. For the sake of example, let’s create rules to structure frontend logic/state in a way similar to Redux. This is just an example, and your production app will have more nuance. i.e. You might choose to use a library for form data, or have a library for fetch requests like TanStack Query or SWR. But we’ll model our directories in a simpler way for the sake of discussion and it will be inspired by Redux. Again, Redux’s pattern provides a place for:
- Action — What can be done in the app
- Reducers — What happens in response to an action (without side effects)
- Thunks/Sagas — Where do we isolate side effects
- Centralized state — Related application state is grouped separate from the component hierarchy
- Selectors — How does application state become transformed for subscribing components?
In place of actions
Let’s make our first rule that handlers on elements are either imported or light wrappers around imported functions. The meat of our application should no longer live in our component, so we know if we’re defining a substantial amount of behavior in our component, the behavior likely has somewhere else it’s intended to live. This rule removes clutter from the component and makes it easier to see how handlers are defined in your template.
In place of reducers
Lets allow the creation of directories called handlers. These handlers represent what happens in response to (generally) user input. They’re not pure functions like reducers, but they don’t define non-pure behavior themselves. They’re only allowed to consume it (i.e., they don’t define things like timers, or fetch requests, but are allowed to import functions that do). Handlers are also allowed to update state. If you’re using React, something like Jotai can make this work where you can update state outside of components.
In place of Thunks/Sagas
Now we have to create space to isolate our handling of side effects. Side effects can vary in how we handle them. Side effects often make sense in hooks since they often have to interact with parts of React state and lifecycles directly for proper handling — i.e. fetch requests have several states (not started, loading, success, error), timers must be cleaned up when the component dismounts, etc. We can generally create these as hooks, and will create a hooks directory where side effects are allowed to live.
Centralized state
We’ll allow directories called store that will house application state. Application state isn’t tied to a single component although we’ll allow UI state to live in components — in other words, whether or not our custom radio button is toggled on or off lives in its own component, but the user’s choice color theme lives in store. The distinction between UI and Application state is useful and makes this structure intuitive.
In place of selectors
There’s a significant testing benefit if you separate how state is derived from the components that consume it. We’ll allow creation of directories called derived to handle derived state. They should generally be housed near the page level component that subscribes to them. If more than one page subscribes to it, it’s usually best to just duplicate the derived value so its more resilient to changing product requirements over time.
The path forward isn’t about adopting Redux specifically, but about recognizing that architecture matters as much in front-end development as it does anywhere else. When we create intentional boundaries between side effects, business logic, and presentation—whether through Redux, something like the patterns outlined here, or your own opinionated structure—we’re investing in the maintainability of our applications. The chaotic feeling that drives so many of us to seek better patterns isn’t inevitable; it’s a symptom of treating components as dumping grounds rather than thoughtfully designed systems. Start small, pick one area of your codebase, and see how much clearer things become when everything has its place.