TIL - React component composition

naseriansuzie·2023년 2월 26일


목록 보기

Today What I Learned

매일 배운 것을 이해한만큼 정리해봅니다.

새로 옮긴 회사에서 Frontend Mastery에서 발행하는 글을 요약하고 토론하는 스터디를 만들었다.
첫번째 주제는 Building future facing frontend Architectures, 스터디에서는 미래 지향적인 프론트엔드 아키텍처 구축로 부르기로 했다. 영어로 주요 내용을 모아봤다.

[출처]: Building Future Facing Frontend Architectures

About this article

  • A deep dive into how component-based frontend architectures can collapse under the weight of complexity at scale, and how to avoid it.

  • Situation: Frontend projects that are worked on by many developers and teams who use any component-based framework.

  • Focus: This guide will specifically focus on component code structure that results in resilient front-ends that can easily adapt to changes.

The Influence of Common Mental Models

  • Mental Model?

    In computer science, a mental model is a way of understanding how a system, software application, or programming language works. It's like a mental picture or framework in your mind that helps you make sense of complex information and solve problems related to that system.
    Having a good mental model is important for developers because it helps them design and optimize software more efficiently. It also helps them communicate with other developers by providing a common language and set of concepts to discuss technical issues.

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

Thinking in components

  • Thinking in React lays out the key mental models on how to think when building frontend applications “the React way”.
  • Questions for the main principles when building 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.
    • 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.
    • Where should the state live? Asking this question is useful to identify what components should depend on what state.

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

Mental Model: Top down vs bottom up

  • 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.

1. Top Down

  • code example
// 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} />
  • Common 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. Its 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.
    • Pros and Cons of Top Down

      In a rapid development phase for MVP or a small structure application, top down gets a speed and achieves the goal.Will lose a simplicit: At scale it’s these rapid culmination of these smaller decisions(not changing or trying to think of restructuring, but just adding more props or furcating logics inside of a component) that add up quickly and start to compound the complexity of our components.
      Related APIs need to be corrected when new requirements come in: 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.
      It becomes a monolithic components that everybody avoids to change: 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.

2. The organic growth of monolithic components

  • 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.
  • other ways monolithic components can lead to things silently imploding.
    1. They arise through premature abstraction. A rush to abstract architecture prematurely to avoid duplication of a code leads to the wrong abstraction.
    2. They prevent code re-use across teams. When another developer of a different team observes a monolithic component and needs partially or needs to add slight variation, they re-implement and fork it into the safety of your own package rather than refactoring or doing some decomposition. Leading to multiple duplicated components all with slight variations and suffering from the same problems.
    3. They bloat bundle sizes. A monolithic components loads everything at once rather than having independent components that can be optimized and only loaded when truly needed by the user. This increases a degradation of performance.
    4. They lead to poor runtime performance. Monolithic components make it very difficult to ensure only the minimal amount of things are re-rendering when that state changes.

3. Bottom Up

  • code example
                <NavItem to="/home">Home</NavItem>
                <NavItem to="/projects">Projects</NavItem>
                <Separator />
                <NavItem to="/settings">Settings</NavItem>
                <LinkItem to="/foo">Foo</NavItem>
                <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>
    • Pros and Cons of Bottom Up
      creating components whose APIs could be reusable even if they aren’t, generally lead to much more readable, testable, changeable and deletable component structures.When you’re trying to ship fast this is an unintuitive approach because not every component needs to be reusable in practice. It can be less intuitive at an early stage.
      It’s a more consumable and adaptable long term approach.
      • benefits compared to the top down mental model
        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.
    • How is a bottom up mental model different to top down? constructing those smaller pieces that can then be composed together. The total complexity is distributed among many smaller single responsibility components.
    • Catch: 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.

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. 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
    In the context of React, we can expose “slots” through children, or render style props that maintain the inversion of control on the consumers side. <Button /> itself could be composed from smaller primitives under the hood.

    // 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} />
  • Open for extension
    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
    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.

Self Questions when building the UI components in isolation that lead to resilient components

  • 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.

Building Resilient Components

  • Name components based on what they actually do.
    Comes back to the single responsibility principle. 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.
  • 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.
  • Be cautious of configuration via props.
    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 renderXrenderY 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

Concluding thoughts

  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.

선한 변화와 사회적 가치를 만들고 싶은 체인지 메이커+개발자입니다.

0개의 댓글