5 Ideas for Flexible React Architectures

One of my least favorite parts of engineering is having to deliver the news that what seems like a small feature request cannot be done without significant effort, or maybe should not be done due to the amount of regression risk the refactor would introduce. It reminds me of the joke about a tourist wandering through the countryside:

Tourist: “How do I get to London?”
Local: “Well I wouldn’t start from here…”

With that in mind, here are five ideas for React component architecture that might save you from starting in the wrong place.

  1. Avoid heavy use of props for styles
  2. Use partial application to create variants
  3. Components are not layouts
  4. Compose at the point of use
  5. Render children whenever possible

1. Avoid heavy use of props for styles

A common source of component complexity is when props are used to imperatively trigger visual treatments. Prop values should declare the state of the component, and not be used as escape hatches to manipulate the CSS treatment.

// good
const StyledButton = styled.button`
  background: ${({ isDisabled }) => isDisabled ? "grey" : "blue"};
  color: ${({ isDisabled }) => isDisabled ? "black" : "white"};
`
const Button = ({ isDisabled }) => (
  <StyledButton isDisabled={isDisabled} />
);

// bad
const StyledButton = styled.button`
  border-color: ${({ borderColor, hideBorder }) => {
    return isDisabled ? "transparent" : borderColor;
  }};
`
const Button = ({ borderColor, hideBorder }) => (
  <StyledButton borderColor={borderColor} hideBorder={hideBorder} />
)

The temptation to reach into a component and manipulate a granular CSS property can often be “design smell” and indicate that the visual design system is either inconsistent or not well mapped to the states of the component.

Things to consider:

  • Am I defining the state of the component? or telling it how to do it?
  • Will this prop value change over the lifecycle of the component?

2. Use partial application to create variants

One way to improve the ergonomics of React components is to pre-apply known prop values into a descriptively named component that maps to a specific use. In the following example, an underlying <Button /> component exports variants that require less configuration at the point of use, and produces clean, declarative code.

const VARIANTS = {
  PRIMARY: "primary",
  SECONDARY: "secondary"
}

const StyledButton = styled.button`
  background: ${(props) => props.variant === VARIANTS.PRIMARY ? "red" : "green"};
`

export const Button = ({ variant }) => <StyledButton variant={variant} />
export const PrimaryButton = props => <Button variant={VARIANTS.PRIMARY} {...props} />
export const SecondaryButton = props => <Button variant={VARIANTS.SECONDARY} {...props} />

A strong design system will often define these variants in advance of implementation in a specific view. It’s important to know beforehand if these styles will need to adapt to changing prop values being fed into the component. If so, partial application is likely not the best approach.

If the style rules are complex, disaggregating the styles that are independent of lifecycle can reduce the complexity of the component’s states.

// handle style functionality common to _all_ buttons
const StyledButton = styled.button`
  && {
    background: ${({ isDisabled }) => isDisabled && 'gray' };
  }
`

export const Button = props => <StyledButton {...props}>{props.children}</StyledButton>

// define the rules that only matter to this button version
const PrimaryStyledButton = styled(Button)`
  background: green;
`

export const PrimaryButton = props => (
  <PrimaryStyledButton variant={VARIANTS.PRIMARY} {...props} />
)

// define the rules that only matter to this button version
const SecondaryStyledButton = styled(Button)`
  background: red;
`

export const SecondaryButton = props => (
  <SecondaryStyledButton variant={VARIANTS.SECONDARY} {...props} />
)

Things to consider:

  • Will this variant be static over the life of the component? or will it need to changed dynamically with prop data?
  • Can the visual states be clearly differentiated to simply render logic?

3. Component are not layouts

Another way that components fail is if sizing constraints, or spacing relative to other components, are hard coded into the component itself. Besides the headache of managing collapsing margins, core components often end up included in other components where external margins can cause problems.

Explicit widths rarely perform well in responsive layouts, and can cause issues if the component needs to be nested inside another component with a width limitation. If an explicit width is required, either provide a one-off width at the point of use, or ensure that the component is top-level and unlikely to ever be contained in another component.

Just like the solution to managing state in React is to lift it up from the component, lifting layout constraints away from a core component will make it simpler and more maintainable as well.

// a menu should be block-level anywhere it's used
export const Menu = ({ items }) => (
  <div data-id="menu">
    {items.map(item => <MenuItem />)}
  </div>
)

// some specific uses might require an explicit width
export const AppDrawer = styled(Menu)`
  width: 500px;
`
export const DropdownMenu = styled(Menu)`
  max-width: 300px;
`

Rules:

  • Don’t include external margin on the component
  • Always expect a component to expand to 100% the width of its container
  • Create one-off containers at the point of use for unique constraints

4. Compose at the point of use

Often it’s tempting to optimize for the terseness of code at the point of use instead of for flexibility in component composition. Consider the following:

// CardList.js
const CardList = () => {
  const cards = useSelector(getCards);
  return (
    <div>
      {cards.map(card => <Card key={card.id} />)}
    </div>
  )
}

// MyPage.js
const MyPage = () = {
  return (
    <div id="my-page">
      <CardList />
    </div>
  )
}

Really clean at the point of use, but what if I want to control between two different card types?

// CardList.js
import { CardA, CardB } from './cards';

const CardList = ({ cardType }) => {
  const cards = useSelector(getCards);
  return (
    <div>
      {cards.map(card => {
        if(cardType === "A") return <CardA key={card.id} />
        return <CardB key={card.id} />
      })}
    </div>
  )
}

// MyPage.js
const MyPage = () = {
  return (
    <div id="my-page">
      <CardList cardType="A" />
    </div>
  )
}

And then what if I want to filter the cards based on the state of some other component on the page?

const identity = a => a;
const CardList = ({ cardType, filterFn = identity }) => {
  const cards = useSelector(getCards);
  const filteredCards = cards.filter(filterFn)
  return (
    <div>
      {filteredCards.map(card => {
        if(cardType === "A") return <CardA key={card.id} />
        return <CardB key={card.id} />
      })}
    </div>
  )
}

// MyPage.js
const MyPage = () = {
  return (
    <div id="my-page">
      <CardList cardType="A" filter={card => card.isActive} />
    </div>
  )
}

You can already see that I’m starting to pass a lot of information around. The page calls the card list and tells it what kind of card to render, and it also provides a filter function that, while flexible, starts to add complexity inside the component.

Now, what if I have a new view where I want to render <CardC />? I’d have to reach into the component and add new conditional logic to render the proper card variant. As the logic here becomes non-trivial, the code becomes harder to follow and introduces regression risk to every instance the component whenever it’s touched. Luckily there’s an easy solution.

const StyledThreeColumns = styled.div`
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
` 
const ThreeColumnLayout = ({ children }) => <StyledThreeColumns>{children}</StyledThreeColumns>

// MyPage.js
const MyPage = () = {
  // life the state up into the page
  const cards = useSelector(getCards); 
  // create your filter logic at the point of use
  const activeCards = cards.filter(card => card.isActive);

  return (
    <div id="my-page">
      <ThreeColumnLayout>
        {cards.map(card => <CardC key={card.id} />)}
      </ThreeColumnLayout>
    </div>
  )
}

I’ve now made the code at the point of use more verbose, but I’ve significantly reduced the overall complexity in the system. My <CardList /> has abstracted away into a more generalized component to <a href="control the layout of the cards, I've gained the flexibility to use this layout with any subcomponent I need, and I've decoupled rendering the data from performing operations on the data. And this brings me to my last point:

5. Use `children` whenever possible

The last rule is one of the most important. Whenever possible, aim to allow flexibility in what a component renders. Even trivial assumptions can get baked into the code when you’re not looking.

const Button = ({ label }) => <button>{label}</button>

What happens if we want to add an icon along with the text? We have to modify the base component:

const Button = ({ Icon, label }) => (
  <button>
    {Icon && <Icon />}
    {label}
  </button>
)

But now what if the icon is in the trailing position? We need to modify the component again, and figure out where the <Icon /> should go. Using children solves all of these problems.

const Button = ({ children }) => <button>{children}</button>

<Button>I am a plain button</Button>

<Button>
  I am a success button!
  <Icon variant="success" />
</Button>

<Button>
  <Icon variant="clock" />
  Click to defuse the bomb!
  <Timer>{props.timeRemaining}</Timer>
</Button>

And if you ever feel the need, it’s much easier to lock down a component using the partial application strategy described above than it is to open it up.

// the flexible base component
const Button = ({ children }) => <button>{children}</button>

// opinionated implementations control access to `children`
const IconOnlyButton = ({ Icon }) => <Button><Icon /></Button>
const TextOnlyButton = ({ text }) => <Button>{text}</Button>

Things to consider:

  • Is your component only passing along information? or changing it?
  • How much variation can you expect in the rendered markup?
  • Can I “lock down” a flexible implementation to solve my immediate need without constraining all future uses?

Conclusion

Much like “no plan of battle survives contact with the enemy”, it’s rare for even carefully thought out design specifications to anticipate all future use cases. Thinking in advance about how a component might easily accommodate different design needs will help keep your implementations from being too opinionated, and save you time down the road.

Note: these examples use Styled Components for illustration, but these approaches should be agnostic to the CSS-in-JS solution you use.