A/N: I prefer to use and am most comfortable with React. In this article, I will be using React examples, but the principles discussed can be applied to any front-end framework such as Angular, Vue, Svelte, etc.
A front-end project should be the kind of project that every developer wants to work on. Working on a front-end project comes with a sense of satisfaction in creating something that people get to interact with, looks visually stunning when done well, and doesn’t require a master’s degree to understand.
So, why are front-ends often the most frustrating pieces of software to develop and often have such a bad developer experience?
Front-ends are generally not difficult to understand, but achieving a consistent design can be challenging due to the ever-changing landscape of technologies, the complex intersection of creative content with engineering, and the difficulties in maturing and aligning design approaches. The problem is not even that different people solve problems differently. It’s that the same person could solve two different problems from a fundamentally different place. Traditionally, addressing problems such as organizing UI structures, testing, cohesion, applying styles, and creating complex interactions could have wildly inconsistent or even contradictory solutions. These choppy waters can contribute to creating a nightmare developer experience.
Using the component as a unifying theme, I am going to try to describe some principles for addressing some of these problems. While each approach deserves a deep dive on its own and requires independent research before adopting, it’s helpful to get an overview of how different approaches in different areas can form a unified design approach for a project.
To demonstrate these principles, I will highlight some snippets from a sample project that is the beginning of a recipe app. The app will have three screens: a homepage, a search page, and a saved recipes page:
- The home page will display the top three rated recipes on the site.
- The search page will display all the recipes and will filter based on the search criteria.
- The saved recipes page will only display recipes that the user has favorited.
A Component-Centered Mindset
Thinking of a website solely in terms of pages is a mistake. Websites are not books or magazines. Instead, a front-end developer is building a system of components. At the most basic level, these components are generic, abstract, and fundamental. As they are combined into more complex UI elements, these components become less abstract and more concrete. By embracing this systematic way of design, we gain the ability to design components on different levels of abstraction, such as a generic “button” and a complex “navigation bar.” These levels of abstraction allow for problem spaces to be split and separation of concerns to be enforced. Then, we can truly design our interface around the content, rather than the other way around.
Brad Frost’s book Atomic Design (based on the popular article of the same name) details a methodology for building interfaces in a hierarchy of components. In atomic design, this hierarchy is structured (mostly) by a chemistry analogy:
- Atoms: The most basic UI elements that cannot be broken down any further. Often these are basic HTML elements. Example: button, input, image.
- Molecules: A simple group of UI elements (atoms) that combine to have a purpose. Example: search input, form.
- Organisms: Relatively complex UI elements that form a section of the interface. Example: navigation header, data table
(Leaving the chemistry behind)
- Templates: A structure for the content being presented on a given page. Example: a dashboard layout consisting of a header, a data table, and a footer.
- Pages: A specific instance of a template with content in place. Example: Admin dashboard, Guest dashboard.
Atomic design is not a specific implementation of a project, but rather a way to think about designing your UI. It provides a language and approach to designing entire interfaces while also giving the necessary design context to address problems at the appropriate level of abstraction. For example, if a page is having trouble with content overflowing a boundary, then this issue can be addressed at the atomic or molecular level.
In terms of atomic design categories, let’s outline some components we could make for our recipe app:
- Atoms: NavButton, Input, Image
- Molecules: RecipeCard
- Organisms: Header, RecipeList, SearchRecipe (combines the SearchBar and RecipeList)
- Templates: AppTemplate
- Pages: HomePage, SearchPage, SavedRecipesPage
This list immediately gives us a clear idea of what we need to build. The component names intuitively depict how these components fit together and what function they serve. Do we start building an app? Maybe! Or maybe there is a better approach – we start building components before building an entire application, enabling us to have a component-centered mindset throughout.
Develop Components in Isolation
When implementing atomic design or any other methodology for systematizing your components, you’re going to have a lot of components, especially as your app grows. Every component can have multiple states, and each state might exhibit slight variations in appearance and behavior across different browsers and screen sizes.
The breadth of an interface’s testing space is enormous, leading to significant overhead when testing these components and their states. To perform testing, you must at least spin up the entire app, and the component you want to test might be buried deep in a user story that requires a highly specific application state to reach. Furthermore, since the same components might be used in several places in various ways, it becomes essential to make the components reusable, reliable, and easy to debug.
Developing components in isolation offers a different approach, eliminating the need to run the whole app or go through the standard workflow to test a component. Instead, an external tool allows us to render components individually and test their various states on demand. This is extremely valuable for testing as we can easily dev and QA test a component. Additionally, we can quickly add a testing library, such as Jest.
One tool that I’ve found to be powerful is Storybook. Storybook stands up a dedicated workbench that renders iframes, which render a component using its actual code in the project. The tool provides a console to modify the component’s input props on the fly, and you can write “stories” to test and document different states of a component. Storybook also makes adding snapshot testing a breeze. For more in-depth information, check out this post by my colleague Mariano Salem: Demystifying Front-End Testing.
To see stories in action, let’s create a couple for our RecipeCard component. It has two basic states: default and saved.
import { Meta, StoryObj } from "@storybook/react";
import RecipeCard from "./recipe-card";
const meta: Meta<typeof RecipeCard> = {
title: "Design System/Molecules/RecipeCard",
component: RecipeCard,
};
export default meta;
type Story = StoryObj<typeof RecipeCard>;
export const Default: Story = {
args: {
recipe: {
// some recipe data
saved: false
},
},
};
export const Saved: Story = {
args: {
recipe: {
// some recipe data
saved: true,
},
},
};
We then can look at the various states of the RecipeCard in complete isolation. Here’s how Storybook looks after writing stories for all of the components in the recipe app example. Notice the atomic design organization.
In the example, I skipped over a step. To apply styling to these components, we need to write some CSS. By doing so, another problem is introduced: CSS was initially intended to work as a global stylesheet. Global scopes are the exact thing we are trying to avoid with our component-centered approach. So, what can we do to address this challenge?
Limit Scope of Styles to the Component
The biggest problem with trying to fit CSS into the component system is a question of scope. If we were to use a traditional CSS stylesheet or a preprocessor like SASS, it would be difficult for us to determine how a specific piece of markup will look like when it’s rendered. Although one might assume that looking up the class name in the stylesheet is the solution, any front-end developer can attest that working with global styles is rarely ever that simple when you render in a browser and look at the dev tools.
Conversely, there is the inverse problem as well. If you want to modify a style rule to fix a component in the markup, how do you know that you will only modify the component you want and not have unintended side effects anywhere?
A component-centered approach to styles would solve this problem of scope, allowing us to look at the markup and know exactly how a component will look when it is rendered. Additionally, it will not lose the power inheritance capabilities of CSS, eliminating the need to repeatedly rewrite the same styles.
There are many great tools for solving this as well as methods of writing CSS. One solution that I think is particularly powerful at adhering to this approach is a tool called styled-components1. Styled-components is a CSS-in-JS library that integrates styles into the components themselves, taking away the need to use className. It has a clean look that makes JSX incredibly easy and intuitive to read. Moreover, styled-components supports inheritance to avoid repeating code and has the benefit that the styles themselves are written with plain CSS syntax in JavaScript backticks. This makes it easy to learn and familiar to those who are more comfortable writing styles the conventional way.
import styled from 'styled-components';
const Title = styled.h1`
font-size: 2rem;
color: #fff;
`;
const Header = styled.header`
display: flex;
padding: 50px;
justify-content: flex-end;
align-items: baseline;
& > *:first-child {
margin-right: auto;
}
`;
Then, the styles can be used just like a component. I’ll add this title to the site header alongside the NavButton and a wrapper div that creates a flexbox to hold everything.
<Header>
<Title>{title}</Title>
<NavButton to="/">Home</NavButton>
<NavButton to="/search">Search</NavButton>
<NavButton to="/saved">Saved</NavButton>
</Header>
One drawback is that these styles are dynamically created, which may result in a slight flicker when the app is first loaded. While there are ways to use CSS-in-JS approaches that generate static stylesheets, they currently tend to be rather complex.
Using Elegant Design Patterns to Simplify Components
One of the greatest benefits I have received from working with more experienced developers is having the opportunity to read and become familiar with mature code. I’ve learned that elegantly utilizing advanced design patterns to simplify otherwise complex interfaces can have a significant impact. When a component is doing too much, it can easily become bloated. Other times, a complex interface can suffer from props drilling, or other code smells, when the design approach is too simplistic.
There are many patterns to address these issues, but here are a few that I think are particularly noteworthy:
- Component context design: This pattern enables sharing data between components without needing to explicitly pass data through each level of the component tree. It helps avoid props drilling and overuse of nested components. This is done by creating a date context with limited scope that is shared among the components that need to interact.
- Render props: This technique involves passing a function as a prop to a component, enabling the component to render or expose functionality to its children’s components. It promotes components to share code and behavior, giving more flexibility and control to the consuming components.
- Higher Order Components (HOCs): This is a design pattern where a function takes a component as input and returns an enhanced or modified version of that component. HOCs enable code reuse and composition, allowing you to add additional functionality to an existing component without modifying the original component code.
The title of this section describes elegant design patterns that can simplify code. Design patterns should be used judiciously. When they are used appropriately, they can be a satisfyingly elegant way to solve a problem. However, when they are forced into an inappropriate use case, they usually do more harm than good. Abstractions can create code smells just as easily as they can remove them.
With that caveat in mind, let’s take a closer look at one of the patterns, component context design, through this example:
const DataContext = createContext<{
searchResults: Recipe[];
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
}>({searchResults: [], setSearchTerm: () => {}});
const SearchRecipe = ({children, recipes}: SearchContainerProps) => {
const [searchResults, setSearchResults] = useState<Recipe[]>([]);
const [searchTerm, setSearchTerm] = useState<string>('');
useEffect(() => {
const filteredResults = recipe.fitler(/* filter recipes here */);
setSearchResults(filteredResults);
}, [searchTerm, recipes])
return (
<DataContext.Provider value={{searchResults, setSearchTerm}}>
{children}
</DataContext.Provider>
);
};
const SearchBar = () => {
const {setSearchTerm} = useContext(DataContext);
return (
<SearchInput onChange={(event: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(event.target.value)} placeholder="Click to search..."/>
);
};
const SearchResults = () => {
const {searchResults} = useContext(DataContext);
return (
<RecipeList recipes={searchResults}/>
);
};
The DataContext sets up a context to share the data between components.
The SearchRecipe component provides the state that stores the search term and search results.
The SearchBar and SearchResults use this context to access the parent’s state.
Then, we can use the components like:
<SearchRecipe recipes={recipes}>
<SearchBarContainer>
<SearchBar />
</SearchBarContainer>
<SearchResultStyleProvider>
<SearchResults />
</SearchResultStyleProvider>
</SearchRecipe>
Again, we can view our component in Storybook before ever setting up the entire web app.
Putting It All Together
Having created all the necessary atoms, molecules, and organisms for our webpage, we can proceed with putting them into templates and pages.
Adopting a component-centered design approach empowers front-end projects and enhances the development experience. By implementing these principles, developers can overcome common challenges and create more robust and enjoyable frontend applications.
Throughout the walkthrough of our recipe app example, we have seen how these principles have come together to provide a clear design structure, promote reusability, simplify testing, and maintain consistent styling. This has great potential to foster team cohesion and opens new possibilities for expanding the capabilities of front-end frameworks, all while encouraging continual growth in front-end development practices.
My hope is that we can start to ask ourselves the question: How can thinking about components enable me to solve problems in a way that is congruent with other problems I have already solved and future problems I might need to solve later? Let’s transform frontend projects into ones that every developer wants to contribute to – projects that are a joy to work on, spark creativity, and push the boundaries of what’s possible in web development.
To see the example project in full, see https://github.com/russell-pier/my-moms-recipes.