Chapter 3. Atomic Design: A Tool for Migration

In the previous chapters, we’ve discussed reasons for when it makes sense to rewrite an application and covered how to decide between a synchronous or asynchronous rewrite strategy. Now we’ll delve more deeply into the principles of atomic design to show how the concepts of building modularly can be used successfully in both synchronous and asynchronous rewrites.

Atomic design shifts focus from designing web pages to thinking in terms of component systems. It breaks up complex interfaces into discrete pieces that can be focused on one at a time, used in a number of different contexts, and put together in growing orders of complexity.

Build Complex Systems out of Simple Parts

As you might recall from the Preface, in atomic design complexity is created by stitching together simple components, much like Lego pieces. Using simple pieces that can be combined to build more complex interfaces is a powerful tool to apply in software engineering. It allows an engineer to reason about larger groups of complex systems, without needing to hold entire context of all the subcomponents in working memory.

Atoms

Atoms are the simplest components in atomic design. They are the basic building blocks that are pieced together to form more complex components (see Example 3-1). Atoms are designed to be used in a number of different contexts and make no assumptions about where the state came from.

Example 3-1. A Button Atom with configuration perameters as properties
import React from 'react';

export default ({
  text,
  onClick,
}) =>
  <button
    onClick={onClick}
  >
    {text}
  </button>;

The Button example can be used in a number of different applications. It doesn’t make assumptions about the text or what happens when it is clicked. Instead, the Button takes configuration parameters and renders the component. This makes the Button highly reusable, but not useful on its own. To be useful, the Button needs context, which can be provided by a parent component or store that holds application state. This means that to create useful features, groups of Atoms are combined or Atoms are tied directly to the application state. Some other examples of Atoms would be Labels, Text Inputs, and Images; they all have a few common properties:

  • No assumed context
  • Not useful on its own
  • Highly reusable

Molecules

Molecules are the next step up in complexity from Atoms. Atoms are connected to form more complex Molecules that are designed to perform a specific task. Much like Atoms, Molecules can be used in a number of different contexts and make no assumptions on where state comes from. The difference is that Molecules are created for a more specific purpose, like a navigation menu or a search input (Example 3-2).

Example 3-2. A SearchInput Molecule
import React from 'react';
import {
  Button,
  Input,
  Label,
} from './Atoms';

// A SearchInput
export default ({
  id,
  onChange,
  onSearch,
  value,
}) =>
  <div>
    <Label for={id}>Search This Site</Label>
    <Input
      id={id}
      type={'text'}
      onChange={onChange}
      value={value}
      placeHolder={'Enter Search Terms'}
    />
    <Button
      onClick={onSearch}
    >
      Search
    </Button>
  </div>

Similarly to the Button example, the SearchInput can be used in a number of applications since it make no assumptions on where state lives. However, the SearchInput is a little different since it is opinionated on how it will be used—for search. The Molecules level is where workflows start to form. Molecules are things like SearchInputs, Forms, and Navagation elements and they all have a few common properties:

  • Made of groups of Atoms
  • No assumed state location
  • Designed for a specific task

Organisms

Organisms are the most complex construct in atomic design and are made of Atoms and Molecules. Useful Molecules are combined to capture a complete workflow or idea that forms an Application. An Organism can stand on its own since it contains the context and business logic to perform a set of tasks (Example 3-3). This means an Organism can be put in front of customers.

Example 3-3. An Organism that can search a list of items
import React, { Component } from 'react';
import {
  SearchInput,
  SearchList,
} from './Molecules';
import {
  Title,
} from './Atoms';

class App extends Component {
  constructor() {
    super();
    this.state = {
      items: ['LaCroix', 'Coffee', 'Orange Juice'],
      searchInputValue: '',
      filterValue: '',
    };
  }

  // handle when the SearchInput value changes
  handleChange({ searchInputValue }) {
    this.setState({
      searchInputValue,
    });
  }

  // handle when the SearchInput button is clicked
  handleSearch() {
    this.setState({
      filterValue: this.state.searchInputValue,
    });
  }

  render() {
    return (
      <div>
        <Title>Search For A Beverage</Title>
        <SearchInput
          id={'search-input'},
          onChange={(e) => this.handleChange({
            searchInputValue: e.target.value,
          })}
          onSearch={() => this.handleSearch()}
          value={this.state.searchValue}
        />
        <SearchList
          items={this.state.items.map(item =>
            item.startsWith(this.state.filterValue))}
        />
      </div>;
    );
  }
}

In this example Organism, the list of items is stored in the local component state. However in a more complex application it is likely the state will get fetched from the server or stored in something like Redux or Mobx. It is important to note that Atoms and Molecules work together at this level. The Title Atom is used along with the the SearchInput and SearchList Molecules. Some characteristics that all Organisms share:

  • Useful on its own
  • Clear where state comes from
  • Constructed from Atoms and Molecules

Templates and Pages

If you are already familiar with atomic design, you might be wondering about Templates and Pages. Templates are the generic structure of a web page (rather like a blog theme that has a header, space for a sidebar, some images, and paragraphs of text) and Pages are specific instances of Templates (think of the blog post itself).

While these are useful concepts for designing things like marketing sites and blogs, they are not very helpful when building single page applications (SPA). In a typical SPA there are a couple of page structures and hundreds (even thousands) of components. In frameworks like React, Vue, and Backbone, the view layer is treated like a Template. So each component ends up being both an Atom (or Molecule) and a Template.

If we expand the Organism’s original definition to include making decisions on where state lives, the concept of the Page in atomic design is unneeded (plus we can stay true to the chemistry metaphor!). An Organism is equivalent to saying “application” or “product” with this new definition. The Page concept goes back to the early web, where content was presented as an endless stream of text that you scrolled through (which was a metaphor for a book page). When building an SPA, the metaphor is closer to a control panel with buttons, labels, and switches. Thinking in terms of screens containing the controls and displays (Atoms and Molecules) is much more helpful in thinking about how to architect an Organism.

Testing

When building systems of components, testing becomes increasingly important. Employing methods of unit and integration testing in the right places can  increase confidence in shared resources like a component libraries, especially when components are pure functions that render the UI.

Pure UI

The idea of pure UI is that an application’s UI is a pure function of application state. Let’s break that down into simpler chunks:

Application UI

What the user interacts with. This might be what a user sees or interacts with using a screen reader.

Pure function

A pure function means that given the same input, a function will always return the same output. An example of this would be an equation that describes a line:

Y=mX+b

Given the parameters m (the slope of the line), X (the horizontal coordinate), and b (the zero offset from Y), the value of Y will always be the same.

An example of an impure function would be a function that returns a random number. With no change in input the function has a different random output each time it is called:

Y=random()
Application state

A representation of the system that is remembered and modified by user interactions. Application state can be passed down as context to the user and modified by business logic triggered by user interactions.

A pure UI means that given the same application state, a user will always get the same UI.

In practice it is difficult to keep 100% of the application state in a store. Things like hover and focus states can be difficult to manage, especially when the browser already keeps track of these states. There are trade-offs to each approach that should be carefully considered.

Snapshot Testing

When testing a component library of Atoms and Molecules, it is important to know that each component will render without breaking the UI and when components change. Snapshot testing is a perfect match for this use case. It is like taking a picture of a component at one time and then later taking another picture and comparing the pictures for differences. If something changes unexpectedly, the test will fail. It is important to note that snapshot testing is about increasing predictability, not test coverage. With snapshot testing in Jest, components are rendered and serialized (Example 3-4).

Example 3-4. A serialized component snapshot
exports[`Snapshots Text bold`] = `
<span
  style={
    Object {
      "fontWeight": 600,
    }
  }
>
  The quick brown fox jumps over the lazy dog
</span>
`;

If someone changes the Text component and does not update the snapshot test, the test suite will fail. This is desirable because it captures intent to change components. Consider Example 3-5, where someone updates the snapshot and does a diff.

Example 3-5. A diff of a changed component snapshot
exports[`Snapshots Text bold`] = `
  <span
    style={
      Object {
-        "fontWeight": 600,
+        "fontWeight": 700,
      }
    }
  >
    The quick brown fox jumps over the lazy dog
  </span>
  `;

It’s pretty easy to understand the changes in the diff in Example 3-5, and for small changes like this (which will be the majority) there is no need to render and visually inspect the component.

Ideally, snapshots are taken of each component in each configuration. In the Text component example, that might mean configuring the component with different colors, weights, and sizes. This ensures that the component will not throw an exception in each configuration. This also allows the developer to visually inspect at each of the configurations, checking that the desired output of the Text component matches the given input. The result is a robust component library of visually inspected components that render successfully in each state.

Integration Testing

Testing each component in isolation makes it easy to spot when things go wrong within a given component. However there are still some blind spots, even if you test every component in every combination with snapshots. Another class of bugs can appear when combining components together to form more complex Molecules and Organisms. To catch these sorts of bugs it is necessary to do some integration testing to make sure groups of components work well together. Let’s take a look at Example 3-6, where an integration test checks if the correct login state is displayed.

Example 3-6. A login state integration test
import { mount } from 'enzyme';
import App from './';

describe('App', () => {
  describe('Login Display', () => {
    it('should display user is logged out', () => {
      const wrapper = mount(
        <App
          isLoggedIn={false}
        />
      );
      const loginDisplayText = wrapper.find('#login-display').text();
      expect(loginDisplayText)
        .toBe('You are currently logged out');
    });
  });
});

Under the hood, the #login-display component is probably implemented with a Text. A Text only knows how to render a string and doesn’t care about the login state. This implies that we have a component somewhere that can translate the login state to a string; let’s call this LoginDisplay. This also implies that the LoginDisplay is passed the login state from the parent application. So we end up with a hierarchy that looks something like this:

(state)AppLoginDisplayText

If you think of each of these as a layer, doing isolated snapshots only tests one layer at a time. Integration tests verify functionality across layers. The previous integration test ensures login state is passed from the App into the LoginDisplay component, the LoginDisplay component translates the login state into a string, and the desired string is rendered by the Text. Integration testing makes sure that all of these components move together to achieve the expected result.

Advantages

Now that you have an understanding to how Atomic Design works in practice and tools that can be applied for building modular, tested applications, we’ll outline some of the key advantages of this approach.

A Complex System of Simple Parts

Atomic design is well suited for doing migrations since it encourages you to break the problem into smaller parts. The smaller components are much easier to understand when compared to a large complex system. The path to gaining an understanding of the whole system can be broken down into bite-sized chunks. As more of the pieces start to fill in it is possible to move to higher-level components until you’ve reached the Organism. Gaining a high-level understanding in a traditionally constructed application often requires complete context and history. In many organizations, this isn’t even possible because people change jobs or change roles.

Another advantage of this approach is that it enables an architect to design the system and then break up the work so the team can work on things in parallel. Each engineer would receive a document describing a subset of the work that had been broken down by the architect. The document would contain a specification of the component API and a design with a description of what the component looks like. Working in this way gets everyone moving towards the same goal in a way that lets implementors make decisions on how something gets implemented.

Over time, as the library of Atoms and Molecules grows, the type of work changes from building components to consuming components. Atoms, which are built free of context, end up being reused over and over again. Team focus is shifted to thinking about what a component does rather than how to implement it. This means that most of the time spent on a feature is on the what and why questions, which will lead to better experiences for the customer.

Developing Components in Isolation

In the simplest approach to building UIs, development is done within the application. However, working within the application doesn’t scale as the system and environment grow more complex. As the application grows, the tools take longer to complete tasks, and developer cycles take longer. Another side effect is that the development environment takes longer to set up. As more tools are added for automation, more time is spent learning and fixing tools rather than building features.

Since atomic design encourages developers to break complexity into smaller pieces, it becomes easier to focus on one part of the application at a time. Since Atoms and Molecules are free of context it is very simple to use a test harness like React Storybook to set the context and observe results. It is up to the developer to set context and decide what is happening at the interfaces. This means that a developer can simulate requests and pass data to the component under test, rather than having to wait for an expensive request.

An example of a test harness is Storybook, which works for several UI frameworks like React, Vue, and others. Storybook allows you to create stories that render a component with a given configuration. The configuration could be set by hand, fetched from an API, or even manipulated in real time with some controls using the knobs plug-in, shown in Figure 3-1.

Figure 3-1. React Storybook knobs plug-in

Setting the context within a test harness like Storybook means more time is focused on what a component does, rather than how it works. This is an advantage because it gives the developer a chance to be a consumer of the component under test and spot interface issues at this level. The test harness only has to process a subset of the application and stub out all of the interfaces, so development cycles when using a test harness are typically shorter.

Gotchas

In this section, we’ll share a few “gotchas” from lessons we learned at Buffer when implementing our component library, and how we resolved these challenges. We’ll also outline some pitfalls to look out for as you undertake your migration to help you avoid some common mistakes.

Huge Snapshot Files

When capturing a high-level Molecule or an Organism, snapshot tests can generate a huge amount of data. Snapshot files end up getting large and make tests take increasing amounts of time. After the duration reaches a certain point (lets say five minutes to run all tests) there’s a risk that engineers start to ignore the results and move on to the next task.

Using shallow rendering can solve this problem and reduce the amount of testing time significantly. Shallow rendering works by only rendering one layer of components. Examples 3-7 and 3-8 show a deeply rendered snapshot and one with shallow rendering, respectively.

Example 3-7. A deeply rendered snapshot
exports[`Snapshots Some Title And Text`] = `
<div>
  <h1>A Title</h1>
  <span>
    The quick brown fox jumps over the lazy dog
  </span>
</div>
`;
Example 3-8. A shallowly rendered snapshot
exports[`Snapshots Some Title And Text`] = `
<div>
  <h1>A Title</h1>
  <Text />
</div>
`;

The shallow renderer creates a placeholder with the name of the component Text along with properties, rather than rendering all of the subcomponents. Treating each shallowly rendered snapshot as a layer means smaller snapshots and more control over what a test will cover.

Mind the Coverage Gaps; Do Deep Rendering as Well

Treating Organisms, Molecules, and Atoms as separate layers yields smaller snapshots but leaves a gap in testing across the layers. Doing integration tests and rendering deeply in a subset of possible states at the Organism level will give better coverage and ensure that components work together nicely.

Atom or Molecule?

One question is, “How do I know if a component is an Atom or a Molecule?” At its core the question is asking, “Where are the boundaries?” If we look back at the description of an Atom and a Molecule, there are a couple of guidelines that are helpful. An Atom should not have state (outside of things like hover state) and is not useful on its own. However, an Atom should still make some decisions and capture a style or idea. An Atom that simply replaces a div tag is probably too small, but creating a Panel component that draws a border might be useful to create a consistent Panel system.

The point where a component captures a workflow (like search) is where the label would change from an Atom to Molecule. The Molecule has an intended purpose though it is still reusable. If there are multiple workflows or purposes in a feature, it is common to break up a feature into multiple Molecules and combine them into a more complicated Molecule. If the Molecule has hidden state, it is either an Organism or the state should be pulled up to a higher level.

Figure 3-2 is an example of a Buffer social media post, with the Atoms and Molecules outlined so you can see how they come together.

Figure 3-2. Components like Image, Text, Buttons, and Cards are Atoms that are combined together to form the Image Post Molecule

Atoms could be reused to form many different Molecules, but the Image Post Molecule would only be useful to display an Image Post.

Accidentally Synchronous Rewrites

A danger of the “build complexity out of simple parts” approach is that teams end up overengineering each layer of Atoms and Molecules. Instead of building what is needed to solve the problem, the team also solves “future” problems. This is an especially easy habit to slide into when working on an Atom library. Imagine working on a button component and creating warningsecondarylarge, and small buttons when you only need a primary button for the first design. When you factor in the time to write tests and do visual inspection and team synchronization costs, it takes four times as long to build the first button. In theory, the unused configurations solve future problems; however in practice it is just as likely that the button configurations will never be used or even reimplemented to match an actual use case.

When teams start overengineering each layer, every Atom and Molecule must solve all current problems as well as any other conceivable problem before it can be released. Eventually this becomes part of the process, and the MVP must be perfect before the team can start moving customers onto it. This is the synchronous rewrite disguised as an asynchronous rewrite.

Product Roadmap and Migration Plan Alignment

In most product engineering teams, it’s not feasible to stop feature development while the engineering team undergoes a rewrite, because the business requires new features and bug fixes to be deployed regularly to users. The product roadmap simply can’t be delayed for the length of time it takes to synchronously migrate the application.

The most important benefit of the asynchronous rewrite is that it allows development teams to move at their own pace. Since new features and fixes can be shipped to the existing product at the same time, the time pressure to complete and roll out the rewrite is greatly reduced.

To take advantage of this benefit, the Atoms that are created in the component library should be driven by the product features that need to be built: the product roadmap drives the migration plan. As we mentioned earlier, this is important so as to not waste time and learning opportunities by building out a whole component library of Atoms and Molecules that are not, and may never end up, being used in features.

The most difficult part of making an asynchronous rewrite successful is if the design of the new components visually looks different to the existing product. There are three main scenarios here: the existing product has a similar design to the new product, the existing product has a different design that is inconsistent, and the existing product has a consistently different design.

Similar Designs

In the case of similar designs between the existing product and the new features coming up in your roadmap, it should be straightforward to align your product roadmap and migration plan as we’ve outlined here.

This might not need to be exactly the same design. For example, an evolution towards a thinner and lighter style would probably be acceptable too. Some atoms would look slightly different over time, but most web teams build features in a slightly evolving style in any case. Whether this slight evolution is acceptable will depend on the level of design consistency your product already has. The more consistent it is now, the less acceptable a piecemeal migration will be.

Differing Designs with an Inconsistent Existing Design

Many products that have been around for a long time have a varied, inconsistent design, owning to a rich history of different product influences over time. Use this to your advantage! By slotting in your new Atoms and Molecules over time, you’ll see eventual convergence on a more consistent style, and users won’t notice a difference as it’s a gradual improvement in consistency.

The middle ground is where your existing design language is consistent enough that introducing different Atoms would make a noticeable negative impact, but critically, the functionality is the same. In this case, building your component library with your existing style guide, and then restyling them in your new style guide once your application has been migrated to components may work best. This allows you to avoid the tremendous pain of refactoring hundreds of thousands of lines of CSS. In this case, you should approach your migration as two-fold: a functional rewrite to use simple, tested components that are defined once and used everywhere, and then secondly, a stylistic overhaul where you would need to (synchronously) restyle your component library. Adding the secondary, synchronous design rewrite after the asynchronous functional rewrite is less risky in this case than undertaking a fully synchronous rewrite.

Differing Designs with a Consistent Existing Design

In the case where the design principles have been completely rethought so that the basic Atoms like buttons have a different style from that of the existing application, the risk of the asynchronous rewrite is that the product ends up mixing two very different design languages while the rewrite is being undertaken, making for a poor user experience. In this situation, slotting in a new Atom or Molecule that’s been very differently styled into the existing product could be very jarring to the user.

In this case, the simple and natural alignment of building out components as product features is needed and shipping these to both new and existing applications is more complex. Picture shipping a flat Navigation bar with simple, outline-style buttons to a web app that otherwise has a skeuomorphic design. With two such conflicting designs, users might even think your product is broken.

In Figure 3-3, you’ll see the strongly skeuomorphic design of the iOS 7 operating system, with its characteristic rounded corners, shadows, and color gradients.

Figure 3-3. iOS 7’s skeuomorphic design

Figure 3-4 shows a contrasting design philosophy, with a flat design, sharp corners, silhouette style icons, and minimal color gradient.

It’s hard to see how one might slot Windows 8 flat icons into an existing application that looks more like iOS 7, with its rounded, skeuomorphic design, and we recommend not trying something so drastic. If this is your situation, a synchronous rewrite is probably going to be the better product and user experience.

Figure 3-4. Windows 8’s flat design

If this is your situation, you can undertake a synchronous rewrite efficiently using atomic design principles, and just avoid the step of shipping the new components to your existing userbase and product. You can still follow many of the principles we outline here, building with components and moving to a codebase that will allow your team to be consistently productive over the long run.

In this chapter, you’ve gained a theoretical understanding of how atomic design can be used as a tool for migrating web applications, and should at this point have a good idea of what to do conceptually and some common pitfalls to avoid. In the next chapter, we’ll go through a step-by-step guide for how to apply these principles in practice and go about migrating your application.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset