DEV Community

Cover image for Building future facing frontend architectures
rem
rem

Posted on • Originally published at frontendmastery.com on

Building future facing frontend architectures

Building frontend architectures that are performant and easy to change is hard at scale.

In this guide we'll explore the main ways complexity can rapidly and silently compound in frontend projects worked on by many developers and teams.

We'll also look at effective ways to avoid getting overwhelmed in that complexity. Both before it's a problem, and after if you find yourself thinking "oh shit, how did this end up getting so complicated?" when you're tasked to add or change a feature.

Frontend architecture is a broad topic with many various aspects. This guide will focus specifically on component code structure that results in resilient frontends that can easily adapt to changes.

The examples given in this guide use React. But the underlying principles can apply to any component based framework.

We'll start from the very beginning. On how the structure of our code is influenced, even before any code is written.

The influence of common mental models

The mental models we have, how we think about things, end up influencing our decisions to a large extent.

In large codebases, it's the culmination of these many decisions being constantly made that result in it's overall structure.

When we build stuff as a team, it’s important to make explicit the models we have, and expect others to have. Because everyone usually has their own implicit ones.

That's why teams end up needing things like shared style guides and tools like prettier. So as a group, we have a shared model of how things should be consistent, what things are, and where things should go.

This makes life much easier. It allows us to avoid the descent into an unmaintainable code base over time with everyone going down their own path.

If you've experienced a project under rapid development by many developers eager to ship, you might have seen how fast things can get out of hand without proper guidelines. And how over time the frontend can get slower and slower as more code is added and runtime performance deteriorates.

In the next few sections we'll look answering the following questions:

  1. What are the most common mental models to have when developing frontend applications using a component based model frameworks like React use?
  2. How do they influence how we structure our components?
  3. What trade-offs are implicit in them, that we can make explicit, that lead to the rapid rise of complexity?

Thinking in components

React is the most popular component based frontend framework. "Thinking in react" is usually the first article you read when first getting started.

It lays out the key mental models on how to think when building frontend applications "the React way". It's a good article because the advice can also apply to any component based framework.

The main principles it lays out allow you to ask the following questions, whenever you need to build a component.

  • What is the one responsibility of this component? Good component API design naturally follows the single responsibility principle, which is important for composition patterns. It’s easy to conflate something simple as easy. As requirements come in and change, keeping things simple is often quite hard as we’ll explore later in the guide.

  • What’s the absolute minimum, but complete, representation of its state? The idea is that it’s better to start with the smallest but complete source of truth for your state, that you can derive variations from.
    This is flexible, simple, and avoids common data synchronization mistakes such updating one piece of state but not the other.

  • Where should the state live? State management is a broad topic outside the scope of this guide. But generally, if a state can be made local to a component, then it should be. The more components depend on global state internally the less reusable they become. Asking this question is useful to identify what components should depend on what state.

Some more wisdom from the article:

A component should ideally only do one thing. If it ends up growing, it should be decomposed into smaller sub components.

The principles are outlined here are simple, battle-tested, and they work for taming complexity. They form the basis for the most common mental model when creating components.

Simple doesn't mean easy though. In practice this is much easier said than done in the context of large projects with multiple teams and developers.

Successful projects often come about from sticking to basic principles well, and consistently. And not making too many costly mistakes.

This brings up two questions we'll explore.

  1. What are the circumstances that prevent the application of these simple principles?

  2. How can we mitigate those circumstances as best as possible?

Below we'll see why over time maintaining simplicity is not always so straight forward in practice.

Top down vs bottom up

Components are the core unit of abstraction in modern frameworks like React. There's two main ways to think about creating them. Here's what thinking in React has to say:

You can build top-down or bottom-up. That is, you can either start with building the components higher up in the hierarchy. In simpler examples, it’s usually easier to go top-down, and on larger projects, it’s easier to go bottom-up and write tests as you build.

More solid advice. At first glance it sounds simple. Like reading "single responsibility is good" it's easy to nod along and move on.

But the distinction between a top-down mental model and bottom-up one, is much more significant than it seems on the surface. When applied at scale, both modes of thinking lead to very different outcomes when one is shared widely as an implicit way of building components.

Building top down

Implied in the quote above is a trade-off between ease in making progress by taking a top down approach for simpler examples, versus a slower more scalable bottom up approach for large projects.

Top down is generally the most intuitive and straight forward approach. In my experience it's the most common mental model developers working on feature development tend to have when structuring components.

What does a top down approach look like? The common advice when given a design to build is to “draw boxes around the UI, these will become your components”.

This forms the basis for the top level components we end up creating. With this approach we often create a coarse grained component to begin with. With what seems like the right boundaries to get started with.

Let’s say we get a design for a new admin admin dashboard we need to build. We go ahead and look at the designs to see what components we'll need to make.

It has a new sidebar nav in the design. We draw a box around the sidebar, and create a story that tells the developers to create the new <SideNavigation /> component.

Following this top down approach, we may think about what props it takes, and how it renders. Let's assume we get the list of the nav items from a backend API. Following our implicit top down model, it wouldn't be surprising to see an initial design something like in the pseudo code below:

    // get list from API call somewhere up here 
    // and then transform into a list we pass to our nav component
    const navItems = [
        { label: 'Home', to: '/home' },
        { label: 'Dashboards', to: '/dashboards' },
        { label: 'Settings', to: '/settings' },
    ]
    ...
    <SideNavigation items={navItems} />

Enter fullscreen mode Exit fullscreen mode

Our top down approach so far seems fairly straight forward and intuitive. Our intention is to make things easy and reusable, consumers just need to pass in the items they want rendered and our SideNavigation will handle it for them.

Some things to note that are common in top down approaches:

  1. We started building at the top level boundary we initially identified as the component we'll need. From the box we drew around in the design.
  2. It's a singular abstraction that handles all the things related to the side navigation bar.
  3. It's API is often "top down" in the sense that consumers pass down the data it needs to work through the top and it handles everything under the hood.

    Often times our components render data directly from a backend data source, so this fits that same model of passing the data "down" into the components to render.

For smaller projects, there's nothing necessarily wrong with this approach. For large codebases with many developers trying to ship fast, we'll see how a top down mental model gets quickly problematic at scale.

Where top down goes wrong

A top down mode of thinking tends to fix itself on a particular abstraction out of the gate to solve the immediate problem at hand.

It's intuitive. It often feels like the most straight forward approach to building components. It also often leads to APIs that optimize for initial ease of consumption.

Here’s a somewhat common scenario. You're on a team, on a project that is under rapid development. You've drawn your boxes and created the story, and now you've merged your new component. A new requirement comes along that requires you to update the side navigation component.

Here's when things can start to get hairy fast. It's a common set of circumstances which can lead to the creation of large, monolithic components.

A developer picks up the story to make the change. They arrive at the scene, ready to code. They’re in the context of the abstraction and API having already been decided.

Do they:

A - Think about whether or not this is the right abstraction. If not, undo it by actively decomposing it before doing the work outlined in their story.

B - Add an additional property. Add the new functionality behind a simple conditional that checks for that property. Write a few tests that pass the new props. It works and is tested. And as a bonus it was done fast.

As Sandy Mets puts it:

Existing code exerts a powerful influence. Its very presence argues that it is both correct and necessary. We know that code represents effort expended, and we are very motivated to preserve the value of this effort. And, unfortunately, the sad truth is that the more complicated and incomprehensible the code, i.e. the deeper the investment in creating it, the more we feel pressure to retain it (the "sunk cost fallacy")

The sunk cost fallacy exists because we are naturally more acute to avoiding loss. When you add time pressure, either from a deadline, or just simply "the story point is a 1". The odds likely are against you (or your team mates) from chosing A.

At scale it's these rapid culmination of these smaller decisions that add up quickly and start to compound the complexity of our components.

Unfortunately we've now failed one of the foundational principles outlined in “Thinking in React”. The easy thing to do, does not often lead to simplicity. And the thing that leads us to simplicity is not easy to do, compared with the alternatives.

Caveats

  • Again context matters here, if you’re in a rush to ship an MVP that was due yesterday, do what you have to do to keep the business or project alive. Technical debt is a trade off and situations call for taking it on.
  • But if you’re working on a product with many teams contributing to it, that has a long term plan, thinking about effective decomposition through continual refactoring is critically important to longevity.

Let's apply this common scenario on to our simple navigation sidebar example.

The first design change comes around. And we need to add the requirement for nav items to have icons, different size texts and for some of them to be links rather than SPA page transitions.

In practice UI holds a lot of visual state. we also want to have things like separators, opening links in a new tab, some to have selected default state, and so on and so forth.

Because we pass down the list of nav items as an array to the side bar component, for each of these new requirements, we need to add some additional properties on those objects to distinguish between the new types of nav items and their various states.

So our type for our now might look something like with type corresponding to whether its a link or a regular nav item:
{ id, to, label, icon, size, type, separator, isSelected } etc.

And then inside the <SideNavigation /> we'll have to check the type and render the nav items based on that. A small change like this is already starting to get a bit of a smell.

The problem here is top down components with APIs like this, have to respond to changes in requirements by adding to the API, and forking logic internally based on what is passed in.

From little things big things grow

A few weeks later a new feature is being requested and there is the requirement for being able to click on a nav item and to transition into a nested sub navigation under that item, with a back button to go back to main navigation list. We also want the ability for admins to be able to reorder the navigation items via drag and drop.

We now need to have the concept of nesting lists and associating sub lists with parent ones, and some items being draggable or not.

A few requirements changes and you can see how things start to get complicated.

What started as a relatively simple component with a simple API quickly grows into something else within a few quick iterations. Let's say our developer manages to get things working in time.

At this point the next developer or team who needs to use or adapt this component is dealing with a monolithic component that requires a complex configuration, that is (let's be real) most likely poorly documented if at all.

Our initial intention of "just pass down the list and the component will take care of the rest" has back fired at this point, and the component is both slow and risky to make changes to.

A common scenario at this point is considering throwing everything away and rewriting the component from scratch. Now that we understand the problems and use-cases it needs to solve from the first round of iterations.

The organic growth of monolithic components

Everything should be built top-down, except the first time.

As we've seen monolithic components are components that try to do too much. They take in too much data, or configuration options through props, manage too much state, and output too much UI.

They often start as simple components, and through the organic growth of complexity as described above, which is more common, end up doing too much over time.

What started as a simple component, within a few iterations (even within the same sprint) as you build the new features can be on the way to becoming a monolithic component.

When this happens to multiple components as teams work on the same codebase under rapid development, the frontend quickly becomes harder to change and slower end for users.

Here are some other ways monolithic components can lead to things silently imploding.

  • They arise through premature abstraction. There is one other subtle gotcha that leads to monolithic components. Related to some common models that get instilled early on as software developers. Particularly the adherence to DRY (don't repeat yourself).

    The fact that DRY is engrained early, and we see a small amount of duplication at the sites where components are being composed. It’s easy to think "that's getting duplicated a lot, it would be good to abstract that into a single component" and we rush into a premature abstraction.

    Everything's a trade-off, but it's far easier to recover from no abstraction than the wrong abstraction. And as we’ll discuss further below starting with a bottom up model allows us to arrive at those abstractions organically, allowing us to avoid creating them prematurely.

  • They prevent code re-use across teams. You’ll often discover another team has implemented, or is working on, something similar to
    what your team needs.

    In most cases it’ll do 90% of what you want, but you want some slight variation.
    Or you just want to re-use a specific part of it’s functionality without having to take the whole thing on.

    If it's a monolithic “all or nothing” component like our <SideNavigation /> it will be harder to leverage that existing work. Rather than taking on the risk of refactoring or decomposing someone else's package. It often becomes easier to just re-implement and fork it into the safety of your own package. Leading to multiple duplicated components all with slight variations and suffering from the same problems.

  • They bloat bundle sizes. How do we allow only the code that needs to be loaded, parsed and ran at the right time?

    In practice there are some components that are more important to show users first. A key performance strategy for larger applications is the coordination of async loaded code in "phases" based on priority.

    In addition to giving components the ability to opt-in and out of being rendered on the server (because ideally we perform the server side rendering as fast as possible only with components that will actually be seen by the user on first paint). The idea here is to defer when possible.

    Monolithic components prevent these efforts from happening because you have to load everything as one big chunky component. Rather than having independent components that can be optimized and only loaded when truly needed by the user. Where consumers only pay the performance price of what they actually use.

  • They lead to poor runtime performance. Frameworks like React that have a simple functional model of state -> UI are incredibly productive. But the reconciliation process to see what has changed in the virtual DOM is expensive at scale. Monolithic components make it very difficult to ensure only the minimal amount of things are re-rendering when that state changes.

    One of the simplest ways to achieve better rendering performance in a framework like React that as a virtual DOM is to separate the components that change from the ones that do change.

    So you when state changes you only re-renders only what is strictly necessary. If you use a declarative data fetching framework like Relay, this technique becomes more and more important to prevent expensive re-rendering of sub-trees when data updates happen.

    Within monolithic components and top down approaches in general, finding this split is difficult, error prone, and often leads to over use of memo().

Building bottom up

Compared to a top down approach, going bottom up is often less intuitive and can be initially slower. It leads to multiple smaller components whose APIs are reusable. Instead of big kitchen sink style components.

When you're trying to ship fast this is an unintuitive approach because not every component needs to be reusable in practice.

However creating components whose APIs could be reusable even if they aren't, generally leads to much more readable, testable, changeable and deletable component structures.

There's no one correct answer on how far things should be broken down. The key to managing this is use the single responsibility principle as a general guideline.

How is a bottom up mental model different to top down?

Going back to our example. With a bottom up approach we are still likely to create a top level <SideNavigation /> but it's how we build up to it that makes all the difference.

We identify the top level <SideNavigation /> but the difference is our work doesn't begin there.

It begins by cataloging all the underlying elements that make up the functionality of the <SideNavigation /> as a whole, and constructing those smaller pieces that can then be composed together. In this way it's slightly less intuitive when getting started.

The total complexity is distributed among many smaller single responsibility components, rather than a single monolithic component.

What does a bottom up approach look like?

Let's go back to the side navigation example. Here's an example of what the simple case might look like:

    <SideNavigation>
        <NavItem to="/home">Home</NavItem>
        <NavItem to="/settings">Settings</NavItem>
    </SideNavigation>
Enter fullscreen mode Exit fullscreen mode

Nothing remarkable there in the simple case. What would the API look like to support nested groups?

    <SideNavigation>
        <Section>
            <NavItem to="/home">Home</NavItem>
            <NavItem to="/projects">Projects</NavItem>
            <Separator />
            <NavItem to="/settings">Settings</NavItem>
            <LinkItem to="/foo">Foo</NavItem>
        </Section>
        <NestedGroup>
            <NestedSection title="My projects">
                <NavItem to="/project-1">Project 1</NavItem>
                <NavItem to="/project-2">Project 2</NavItem>
                <NavItem to="/project-3">Project 3</NavItem>
                <LinkItem to="/foo.com">See documentation</LinkItem>
            </NestedSection>
        </NestedGroup>
    </SideNavigation>

Enter fullscreen mode Exit fullscreen mode

The end result of a bottom up approach is intuitive. It takes more upfront effort as the complexity of the simpler API is encapsulated behind the individual components. But that's what makes it a more consumable and adaptable long term approach.

The benefits compared to our top down approach are many:

  1. Different teams that use the component only pay for the components they actually import and use.
  2. We can also easily code split and async load elements that are not an immediate priority for the user.
  3. Rendering performance is better and easier to manage because the only the sub-trees that change due to an update need to re-render.
  4. We can create and optimize individual components that have a specific responsibility within the nav. It's also more scalable from a code structure point of view, because each component can be worked on and optimized in isolation.

What's the catch?

Bottom-up is initially slower, but in the long term faster, because it’s more adaptable. You can more easily avoid hasty abstractions and instead ride the wave of changes over time until the right abstraction becomes obvious. It’s the best way to prevent the spread of monolithic components.

If it's a shared component used across the codebase like our sidebar nav, building bottom up often requires slightly more effort for the consumer side of things to assemble the pieces. But as we've seen this is a trade off worth making in large projects with many shared components.

The power of a bottom-up approach is that your model starts with the premise “what are the simple primitives I can compose together to achieve what I want” versus starting out with a particular abstraction already in mind.

"One of the most important lessons of Agile software development is the value of iteration; this holds true at all levels of software development, including architecture"

A bottom up approach allows you to iterate better in the long term.

Next let's recap some useful principles to keep in mind that make build this way easier:

Strategies for avoiding monolithic components

  • Balancing single responsibility vs DRY.

    Thinking bottom up often means embracing composition patterns. Which often means at the points of consumption there can be some duplication.

    DRY is the first thing we learn as developers and it feels good to DRY up code. But it’s often better to wait and see if it’s needed before making everything DRY.

    But this approach lets you “ride the wave of complexity” as the project grows and requirements change, and allows abstract things for easier consumption at the time it makes sense to.

  • Inversion of control

    A simple example to understand this principle is the difference between callbacks and promises.

    With callbacks you won’t necessarily know where that function is going, how many times it will be called, or with what.

    Promises invert the control back to the consumer so you can start composing your logic and pretend as if the value was already there.

        // may not know onLoaded will do with the callback we pass it
        onLoaded((stuff) => {
            doSomethingWith(stuff);
        })
    
        // control stays with us to start composing logic as if the
        // value was already there
        onLoaded.then(stuff => {
            doSomethingWith(stuff);
        })
    
    

    In the context of React, we can see this achieved through component API design.

    We can expose “slots” through children, or render style props that maintain the inversion of control on the consumers side.

    Sometimes there is an aversion to inversion on control in this regard, because there is the feeling consumers will have to do more work. But this is both about giving up the idea you can predict future, and opting to empower consumers with flexibility.

        // A "top down" approach to a simple button API
        <Button isLoading={loading} />
    
        // with inversion of control
        // provide a slot consumers can utilize how they see fit
        <Button before={loading ? <LoadingSpinner /> : null} />
    

    The second example is more both more flexible to changing requirements and more performant, because the <LoadingSpinner /> no longer needs to be a dependency inside the Button package.

    You can see the subtle differences in top down versus bottom up here. In the first example we pass down data and let the component handle it. In the second example we have to do a bit more work but ultimately it's a more flexible and performant approach.

    It's also interesting to note that <Button /> itself could be composed from smaller primitives under the hood. Sometimes a particular abstraction has many different sub behavioral elements underneath that can be made explicit.

    For example we could break it down further into things like Pressable that apply to both buttons and things like Link components, that can combine to create things like a LinkButton. This finer grained breakdown is usually left for the domain of design system libraries, but worth keeping in mind as product focussed engineers.

  • Open for extension

    Even when using composition patterns to build bottom up. You’ll still want to export specialized components with a consumable API, but built up from smaller primitives. For flexibility, you can also expose those smaller building blocks that make up that specialized component from your package as well.

    Ideally your components do one thing. So in the case of a pre-made abstraction, consumers can take that one thing they need and wrap it to extend with their own functionality. Alternatively they can just take a few primitives that make up that existing abstraction and construct what they need.

  • Leveraging storybook driven development

    There is usually a ton of discrete state that ends up getting managed in our components. State machine libraries are becoming increasingly popular for good reasons.

    We can adopt the models behind their thinking when building out our UI components in isolation with storybook and have stories for each type of possible state the component can be in.

    Doing it upfront like this can avoid you realizing that in production you forgot to implement a good error state.

    It also helps to identify all the sub components that will be needed to build up to the component that you are working on.

    • Is it accessible?
    • What does this look like when it's loading?
    • What data does it depend on?
    • How does it handle errors?
    • What happens when only a partial amount of data is available?
    • What happens if you mount this component multiple times? In other words what kind of side effects does it have, and if it manages internal state would we expect that state to be consistent?
    • How does it handle “impossible states” and the transitions between those states. E.g if it has a loading and error props what happens if they are both true? (In this example it’s probably an opportunity to rethink the component API)
    • How composable is it? Thinking about its API.
    • Are there any opportunities for delight here? E.g subtle animations done well.

Here are some more common situations to avoid that prevent building resilient components:

  • Name components based on what they actually do. Comes back to the single responsibility principle. Don't be afraid of long names if they make sense.

    It’s also easy to name a component slightly more generic than what is actually does. When things are named more generically than what they actually do, it indicates to other developers that it is the abstraction that handle everything related to X.

    So naturally when new requirements comes it stands out as the obvious place to do the change. Even when it might not make sense to do so.

  • Avoid prop names that contain implementation details. Especially so with UI style "leaf" components. As much as you can it's good to avoid adding props like isSomething where something is related to internal state or a domain specific thing. And then have that component do something different when that prop is passed in.

    If you need to do this, it’s clearer if the prop name reflects what it actually does in the context of that component consuming it.

    As an example, if the isSomething prop ends up controlling something like padding, the prop name should reflect that instead, rather than have the component be aware of something seemingly unrelated.

  • Be cautious of configuration via props. Comes back to inversion of control.

    Components like <SideNavigation navItems={items} /> can work out fine if you know you’ll only have one type of child (and you know for certain this definitely won't change!) as they can also be typed safely.

    But as we've seen it's a pattern that's hard to scale across different teams and developers trying to ship fast. And in practice tend to be less resilient to change and tend to grow in complexity quickly.

    As you’ll often end up wanting to extend the component to have a different, or additional type of child. Which means you’ll add more stuff into those configuration options, or props, and add forking logic.

    Rather than have consumers arrange and pass in objects, a more flexible approach is to export the internal child component as well, and have consumers compose and pass components.

  • Avoid defining components in the render method. Sometimes it might be common to have "helper" components within a component. These end up getting remounted on every render and can lead to some weird bugs.

    Additionally having multiple internal renderX, renderY methods tend to be a smell. These are usually a sign a component is becoming monolithic and is a good candidate for
    decomposition.

Breaking down monolithic components

If possible refactor often and early. Identifying components likely to change and actively decomposing them is a good strategy to bake into your estimates.

What do you do when you find yourself in a situation where the frontend has grown overly complex?

There's usually two options:

  1. Rewrite things and incrementally migrate to the new component
  2. Break down things down incrementally

Going into component refactoring strategies is outside the scope of this guide for now. But there are a bunch of existing battle-tested refactoring patterns you can utilize.

In frameworks like React, "components" are really just functions in disguise. Sp you can replace the word "function" with component in all the existing tried and true refactoring techniques.

To give a few relevant examples:

Concluding thoughts

We covered a lot of ground here. Let's recap the main takeaways from this guide.

  1. The models we have affect the many micro-decisions we make when designing and building frontend components. Making these explicit is useful because they accumulate pretty rapidly. The accumulation of these decisions ultimately determine what becomes possible - either increasing or reducing the friction to add new features or adopt new architectures that allow us to scale further (not sure about this point or merge it below).

  2. Going top down versus bottom up when constructing components can lead to vastly different outcomes at scale. A top down mental model is usually the most intuitive when building components. The most common model when it comes to decomposing UI, is to draw boxes around areas of functionality which then become your components. This process of functional decomposition is top down and often leads to the creation of specialized components with a particular abstraction straight away. Requirements will change. And within a few iterations it’s very easy for these components to rapidly become monolithic components.

  3. Designing and building top down can lead to monolithic components. A codebase full of monolithic components results in an end frontend architecture that is slow and not resilient to change. Monolithic components are bad because:

    • They are expensive to change and maintain.
    • They are risky to change.
    • It’s hard to leverage existing work across teams.
    • They suffer poor performance.
    • They increase the friction when adopting future facing techniques and architectures that are important to continue scaling frontends such as effective code-splitting, code-reuse across teams, loading phases, rendering performance etc.
  4. We can avoid the creation of monolithic components
    by understanding the underlying models and circumstances that often lead to the creation premature abstractions or the continued extension of them.

    React lends itself more effectively to a bottom up model when designing components. This more effectively allows you to avoid premature abstractions. Such that we can "ride the wave of complexity" and abstract when the time is right. Building this way affords more possibilities for component composition patterns to be realized. Being aware of how costly monolithic components truly are, we can apply standard refactoring practices to decompose them regularly as part of everyday product development.

Related readings

Top comments (0)