DEV Community

Cover image for Making Good Component Design Decisions in React
Boris Ablamunits
Boris Ablamunits

Posted on

Making Good Component Design Decisions in React

Most of us who are using React love it for its declarative nature and how it encourages us to think about complex applications as a composition of multiple UI components.

However, as developers, we don’t always spend enough time to think about our component design and how components will scale and change with our application. At one point, you might start noticing massively complex pieces of code across your codebase and wonder what’s going on. In fact, if you have worked on a project long enough you might not even realize there’s a problem, until a fellow team member asks to be walked through a certain piece of code.

Imagine you are both looking at a complex component that is part of a feature that needs to be extended. You might find that reading and understanding the code requires carefully following different props to get an idea of how user interaction changes the data this component receives. On top of that, you might also need to follow those same props in some wrapping parent component (just one level up, if you are lucky) to determine where the state of each prop is, and how that data is then used, for example, by an API.

If you have been in a situation where reasoning about a particularly complex component produced some discomfort or confusion, it is good to realize that this is likely a side effect of component design being an afterthought, rather than a crucial step in UI development. So why don’t we care more about component design?

Every codebase has its complexities. Building a product and quickly delivering features to users brings more value to your team (and business) than having over engineered solutions. The nature of React lets you quickly compose with reusable components and add missing functionality by passing a couple of more props, but it is our responsibility as engineers to consider solutions and approaches that are resilient to change and assume that our products will evolve. The future of the product and good component design should be on your mind, but it is often forgotten. Sometimes it’s hard to wrap your head around how to translate a visual design or a requirement to a functioning, testable React component. Perhaps the feature you’re about to build seems complex or maybe you’re dealing with a component that seems to have a lot of responsibility. Or maybe you struggle seeing how an existing component that is already overloaded with tens of props can be extended or reused in your app.

Here is one approach that I like to follow when thinking about the design of my components early in a feature’s lifecycle.

Thinking of components in isolation

Let’s think about React components in general terms. React components are functions. Regardless of how you implement a specific component — as a class or as a function — your component probably takes some props as arguments and returns JSX that describes what would eventually be rendered in the DOM. With React, we aim for writing our components as pure functions with respect to their props. Meaning, for any given set of props, our components should return the same JSX.

Following the Single-responsibility principle, a function — and therefore a React component — should be doing one thing. For example, a component that only renders a user’s avatar given an image URL would be considered a component that follows this principle. On the other hand, the principle would be considered broken if you have a component that renders a user’s avatar if it exists, and calls an API to generate a random image if it doesn’t.

Thinking about React components in isolation and identifying the role each component plays on its own will keep you from writing overly complex, multi-purposed code. Pure components with a single responsibility means less props which, in turn, yields a component that is easier to test and easier to reason about.
With this in mind, how can we actually determine what the responsibility of the component really is? And how can we keep it resilient to change?

Thinking in terms of “value” & “onChange”

We saw that we can generally think about UI components as functions that take some data and return a visual representation of that data. We can think about a simple input component in a similar way. A text input component (an input with a type="text" attribute) takes a string as its data and renders an input field with that value. Similarly, a checkbox input component takes a boolean and renders a checked or unchecked box. You might notice that an input field and a checkbox represent different data types — a string and a boolean respectively. Understanding this can help you approach the design of your own components in a different way.

Imagine you are working on a new input component for shopping items which will be used within a larger shopping form. It should look like this:

Alt Text

The user interacting with this component should be able to type in items into the field and every item that’s added should be shown as a little label. The user can then keep on adding more items or remove any existing item by clicking the “x” button on the label. It should also be possible to clear all the labels by clicking “clear all”.

Take a moment to think what data type this component represents? How will the component change that data over time?

You might notice that this component represents a list of strings — the shopping items the user has added. A good way to represent this data is simply by using an array of strings. I like calling this the component’s value.

// An InputWithLabels component used in a ShoppingForm
function ShoppingForm() {
  const [shoppingItems] = useState(['Apples', 'Cookies']);

  return (
    <InputWithLabels
      value={shoppingItems}
    />    
  )
}

For simplicity, let’s keep the shopping items as strings. In a real-world application individual items in this kind of use case can be more complex, but the same principle still applies.

The next thing to consider is the changes the component can make to its value. We mentioned in the requirements that this component should allow adding & removing individual items, as well as having a “clear all” functionality. You could choose the following approach:

function ShoppingForm() {
  const [shoppingItems, setShoppingItems] = useState(['Apples', 'Cookies']);

  const onAddItem = (itemToAdd) => {
    setShoppingItems([...shoppingItems, itemToAdd]);
  };

  const onRemoveItem = (itemToRemove) => {
    const updatedItems = shoppingItems.filter(item => item !== itemToRemove);
    setShoppingItems(updatedItems);
  }

  const onClickClearAll = () => {
    setShoppingItems([]);
  }

  return (
    <InputWithLabels
      value={shoppingItems}
      onAddItem={onAddItem}
      onRemoveItem={onRemoveItem}
      onClickClearAll={onClickClearAll}
    />
  )
}

You might have noticed that in this example, as well as the first one, I have omitted the implementation code for the InputWithLabels component. Regardless of its implementation detail, designing InputWithLabels such that it uses multiple callback props comes with some downsides. The first problem that arises is prop bloat. The complexity of the component increases solely by the fact that it requires 3 callback props to perform its duty. The second problem is that the parent component (in our case that’s ShoppingForm) is responsible for updating the data each time any of the callback props are called, and before persisting that in its state. This means that if InputWithLabels is reused elsewhere, that logic will need to be re-implemented.

Remember that our component’s value is an array of strings. Instead of having individual props for each required functionality’s handler, let’s focus on how our value is changed by the component depending on its different functions:

  • When a label is added, a new string is added to the strings currently in the value array.

  • When a label is removed, a string is removed from the value array.

  • When clicking “clear all”, all items in the value array are removed.

We can see that given the required functionalities we are able to describe the changes made to our value. Therefore instead of having individual multiple props on our InputWithLabel component, we can use a single callback prop. I call this the component’s onChange.

function ShoppingForm() {
  const [shoppingItems, setShoppingItems] = useState(['Apples', 'Cookies']);

  return (
    <InputWithLabels
      value={shoppingItems}
      onChange={setShoppingItems}
    />
  )
}

function InputWithLabels(props) {
  const onAddItem = (itemToAdd) => {
    props.onChange([...shoppingItems, itemToAdd]);
  };

  const onRemoveItem = (itemToRemove) => {
    const updatedItems = shoppingItems.filter(item => item !== itemToRemove);
    props.onChange(updatedItems);
  }

  const onClickClearAll = () => {
    props.onChange([])
  }

  // Your own implementation of this component. Go wild!
  return (
    <div>
      {props.value.map((label) => renderLabel(label))}
    </div>
  )
}

The function passed to the onChange prop will be called with the updated value whenever it needs to change. This way, the parent component is not aware of the implementation detail (how the value is changing). It can assume the data is correct and just update the state, call an API or do other “smart” things. Any other component that uses InputWithLabel can make the same assumption and does not need to re-implement the same logic over and over.

Why this matters

By thinking about our component’s value and onChange, we are able to describe the data structure that best represents our component, as well as how the component is changing over time. Following this approach helps design components with a predictable data flow, making the purpose of your components in a complex app easier to understand and reason about. As your application changes and grows and you build your app on top of components that are designed this way, extending functionality becomes a question of supporting a new change to the component’s value, or changing the value’s data structure if appropriate. Equally as important, it enables you to promote a consistent code style across your entire app — focusing building components around two main props, which can prove particularly valuable when building design systems.

Unit testing UI components in isolation becomes trivial as well. Because we are treating our component as a function of value and onChange props, it is easy to write tests that assert an onChange callback is called with an expected value based on interactions with our component.

Lastly, I find that this approach shines even brighter with TypeScript. For every component you build, you could explicitly specify types for value and onChange, or alternatively create a reusable generic type to use with all of your components following this pattern:

type InputWithLabelsProps = {
  value: string[];
  onChange: (value: string[]) => void;
};

// Or, create a reusable generic type

type ValueComponent<T> = {
  value: T;
  onChange: (value: T) => void;
}

type InputWithLabelsProps = ValueComponent<string[]>;

Of course, as with anything, this is not a silver bullet solution for all of the problems you might encounter. There will be cases where this pattern would not fit at all, or components where additional props on top of value and onChange are totally justified — like search fields or paginated lists. This is just one approach to component design and there are other well documented approaches and patterns, each being valid for different classes of problems. It is up to you and your team to gradually build your approach as your product grows, and stay mindful to how your code changes as your product complexity grows.

Enjoy coding!

Top comments (0)