TIL - Advanced React component composition

김수지·2023년 3월 8일
0

TILs

목록 보기
38/39
post-thumbnail

Today What I Learned

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


회사에서 Frontend Mastery에서 발행하는 글을 요약하고 토론하는 스터디를 만들었다.
이번주 주제는 Advanced React component composition, 스터디에서는 고급 리액트 컴포넌트 합성으로 제목을 정했다. 이번에도 영어로 주요 내용을 모아봤다.

[출처]: Advanced React component composition


About this article

  • A deep dive on composition in React. Learn the key principles for designing and building reusable components that scale.
  • The composition of independent components helps to break stuff down into understandable pieces.
  • This post will dive deep into the main principles used to break down components and design composable APIs in order to building components that are meant to be reused.

What is a composition based API?

<select id="cars" name="cars">	
  <option value="audi">Audi</option>
  <option value="mercedes">Mercedes</option>
</select>
  • In React, this pattern is called the ‘compound component’. Its main idea is multiple components work together to achieve the functionality of a single entity.
  • For many commonly used frontend components, a composition-based API is a good defense against these unforeseen use-cases and changing requirements.

Designing composable components

  • A principle that helps with component API design is the stable dependency principle.
    1. As consumers of a component or package, we want to be able to depend on things with a high chance of remaining stable over time, allowing us to get our job done.
    2. Inversely, as makers of a component or package, we want to encapsulate things that are likely to change to protect consumers from any volatility.
  • In example of Tab component,
    import { Tabs, TabsList, Tab, TabPanel } from '@mycoolpackage/tabs'
    <Tabs>
      <TabsList>
        <Tab>first</Tab>
        <Tab>second</Tab>
      </TabsList>
      <TabPanel>hey there</TabPanel>
      <TabPanel>friend</TabPanel>
    </Tabs>
    • How do we determine what components are stable? Following the stable dependency principle means if we change our approach to all the internal logic, the consumers that import these components don’t need to change anything.
    • Challenges: to get it to work, it requires some jobs done behind the scene.

Underlying problems to solve

  • Internal orchestration between components: one of the biggest challenges of micro-services is connecting all the nodes to make them collaborate without creating tight couplings.
  • Rendering arbitrary children:
    • If an arbitrary component is added to a Tap component’s sub-child, they shouldn’t impact on Tab component’s originated orders or any events related to it.
      <Tabs>
        <TabsList>
          <CustomTabComponent />
          // etc ...
          <ToolTip message="cool">
            <Tab>Second</Tab>
          </ToolTip>
        </TabsList>
        // etc ...
        <AnotherCustomThingHere />
        <Thing>
          <TabPanel>// etc ..</TabPanel>
        </Thing>
        //...
      </Tabs>
    • To solve this problem, there’re two main approaches
      1. Keep track of all of our components in React
        • In this approach, we store the elements and their relative ordering in a data structure somewhere. This approach gets a bit complex.
        • The way to handle this is for sub-components to “register” and “deregister” themselves when they mount and unmount, so the top-level parent can access them directly.
        • e.g) Reach UI, Chakra
      2. Read from the DOM
        • In this approach, we attach unique ids or data-attribute on the underlying HTML our components render. This allows us to get the next or previous elements by querying the DOM with the indexes stored within those HTML attributes.
        • This is a much simpler approach implementation-wise but breaks away from the idiomatic React style of code.
        • The great thing about encapsulation and the stable dependency principle is we can hide all the messy details.

Implementing our Tabs component

Solving the orchestration problem

  • Cloning elements: React provides an API to clone an element
    that allows us to pass in new props “under the table”.

    React.Children.map(children, (child, index) =>
      React.cloneElement(child, {
        isSelected: index === selectedIndex,
        // other stuff we can pass as props "under the hood"
        // so consumers of this can simply compose JSX elements
      })
    )
  • Limitations with the cloning approach: the cloning approach won’t work with wrapped components.

    <Tabs>
      <TabsList>
        //can't do this
        <ToolTip message="oops">
          <Tab>One</Tab>
        </ToolTip>
      </TabsList>
    </Tabs>
  • Render props: Another option is to expose the data and attributes from render props. A render prop exposes all the necessary properties (like an internal onChange for example) back out to consumers who can use that as the “wire” to hook up to their own custom component. This is a way of composition.

    <InlineEdit
        editView={(props) => (
          // expose the necessary attributes for consumers
          // so they can place them directly where they need to go
          // in order for things to work together
          <SomeWrappingThing>
            <AnotherWrappingThing>
              <TextField {...props }/>
            </AnotherWrappingThing>
          </SomeWrappingThing>
        )}}
        // ... etc
      />
  • Using React Context: This is a flexible and straightforward approach. Where the sub-components read from shared contexts. But be careful with optimizing re-renders. We can break things up using the question, “what is the complete but minimal state for each component”?

Building the components

  • Starting with the state, we will break up each components state into separate contexts.
const TabContext = createContext(null)
const TabListContext = createContext(null)
const TabPanelContext = createContext(null)

export const useTab = () => {
  const tabData = useContext(TabContext)
  if (tabData == null) {
    throw Error('A Tab must have a TabList parent')
  }
  return tabData
}

export const useTabPanel = () => {
  const tabPanelData = useContext(TabPanelContext)
  if (tabPanelData == null) {
    throw Error('A TabPanel must have a Tabs parent')
  }
  return tabPanelData
}

export const useTabList = () => {
  const tabListData = useContext(TabListContext)
  if (tabListData == null) {
    throw Error('A TabList must have a Tabs parent')
  }
  return tabListData
}
  • Breaking large states into smaller micro states leads us to achieve,
    1. Easier to optimize re-renders for smaller chunks of state.
    2. Clear boundaries on what manages what (single responsibility).
    3. If consumers need to implement a totally custom version of Tab, they can import these state management hooks to be used like a “headless ui”. So at the very least we get to share common state management logic.
  • Tab and TabPanel
    export const Tab = ({ children }) => {
      const tabAttributes = useTab()
      return <div {...tabAttributes}>{children}</div>
    }
    
    export const TabPanel = ({ children }) => {
      const tabPanelAttributes = useTabPanel()
      return <div {...tabPanelAttributes}>{children}</div>
    }
  • TabsList
    export const TabsList = ({ children }) => {
      // provided by top level Tabs component coming up next
      const { tabsId, currentTabIndex, onTabChange } = useTabList()
      // store a reference to the DOM element so we can select via id
      // and manage the focus states
      const ref = createRef()
    
      const selectTabByIndex = (index) => {
        const selectedTab = ref.current.querySelector(`[id=${tabsId}-${index}]`)
        selectedTab.focus()
        onTabChange(index)
      }
      // we would handle keyboard events here
      // things like selecting with left and right arrow keys
      const onKeyDown = () => {
        // ...
      }
      // .. some other stuff - again we're omitting styles etc
      return (
        <div role="tablist" ref={ref}>
          {React.Children.map(children, (child, index) => {
            const isSelected = index === currentTabIndex
            return (
              <TabContext.Provider
                // (!) in real life this would need to be restructured
                // (!) and memoized to use a stable references everywhere
                value={{
                  key: `${tabsId}-${index}`,
                  id: `${tabsId}-${index}`,
                  role: 'tab',
                  'aria-setsize': length,
                  'aria-posinset': index + 1,
                  'aria-selected': isSelected,
                  'aria-controls': `${tabsId}-${index}-tab`,
                  // managing focussability
                  tabIndex: isSelected ? 0 : -1,
                  onClick: () => selectTabByIndex(index),
                  onKeyDown,
                }}
              >
                {child}
              </TabContext.Provider>
            )
          })}
        </div>
      )
    }
  • Tabs
    export const Tabs = ({ id, children, testId }) => {
      const [selectedTabIndex, setSelectedTabIndex] = useState(0)
      const childrenArray = React.Children.toArray(children)
      // with this API we expect the first child to be a list of tabs
      // followed by a list of tab panels that correspond to those tabs
      // the ordering is determined by the position of the elements
      // that are passed in as children
      const [tabList, ...tabPanels] = childrenArray
      // (!) in a real impl we'd memoize all this stuff
      // (!) and restructure things so everything has a stable reference
      // (!) including the values pass to the providers below
      const onTabChange = (index) => {
        setSelectedTabIndex(index)
      }
      return (
        <div data-testId={testId}>
          <TabListContext.Provider value={{ selected: selectedTabIndex, onTabChange, tabsId: id }}>
            {tabList}
          </TabListContext.Provider>
          <TabPanelsContext.Provider
            value={{
              role: 'tabpanel',
              id: `${id}-${selectedTabIndex}-tab`,
              'aria-labelledby': `${id}-${selectedTabIndex}`,
            }}
          >
            {tabPanels[selectedTabIndex]}
          </TabPanelsContext.Provider>
        </div>
      )
    }
  • a few implementation details that would be required for an actual implementation.
    • Adding a “controlled” version that consumers can hook into implement their own onChange event
    • Extending the API to include default selected tab, tab alignment styles etc etc
    • Optimizing re-renders
    • Handling RTL styles and internationalization
    • Making this type safe
    • Option to caching previously visited tabs (we unmount the currently selected tab when we change tabs)

Testing our components

  • Generally we want to test from the perspective of the end user. This testing best practice is known as black box testing.
  • A great comprehensive example of this type of testing can be seen in the test names here in Reach-UI’s implementation of Tabs.
    • Basic rendering case
    • Rendering as any html element case
    • Rendering with a custom component case
    • User event test cases

How does this scale?

  1. Sharing code between teams
    • With composition APIs powered by inversion of control, we focus on reusing the stable core underlying things, which turns out to be much more effective.
  2. Performance
    • Smaller independent components with clear boundaries make it easier to code-split components that are not immediately required or loaded on interaction, for example.
    • Having independent components makes it easier for React to see what needs to be re-rendered, compared to re-rendering a big monolithic component every time.

Composition all the way up

  • Our Tabs component exists as the composition of smaller components that build up to it. This pattern applies all the way up to the root of our application.
  • Features exist from the relationship between the different components composed together. The application exists as the relationship between different features.
  • software engineering principle of layering:
    1. Base layer: common set of design tokens, constants, and variables used by a shared component library.
    2. Primitive components: and utilities within a component library that composes the base layer to help build up to the components made available in the component library. E.g a Pressablecomponent used internally by a Button and Link.
    3. Shared component library: composes shared utilities and primitive components to provide a set of commonly used UI elements - like buttons and tabs, and many others.
    4. Product specific adaptions of commonly shared components:
      e.g “organisms” that are commonly used within a product that may wrap a few component library components together.
      1. Product specific specialized components: For example, in our product the tabs component may need to call out to an API to determine what tabs and content to render. The nice thing about components is that we can wrap it up as a <ProductTabs /> that uses our Tab component underneath.

Recap

  • Stable dependency principle: Is about creating APIs and components with the end user always in mind. We want to depend on things unlikely to change and hide the messy parts.
  • Single responsibility principle: Is about encapsulating a single concern. Easier to test, maintain and importantly - compose.
  • Inversion of control: Is about giving up the idea we can foresee every future use case and empower consumers to plug in their own stuff.
  • Trade-offs: The main tradeoff for components like that is the external orchestration required by consumers in order to use the components in the intended way. This is where clear guidelines, detailed documentation and copy and pastable example code helps mitigate this tradeoff.
profile
선한 변화와 사회적 가치를 만들고 싶은 체인지 메이커+개발자입니다.

0개의 댓글