매일 배운 것을 이해한만큼 정리해봅니다.
회사에서 Frontend Mastery에서 발행하는 글을 요약하고 토론하는 스터디를 만들었다.
이번주 주제는 Advanced React component composition
, 스터디에서는 고급 리액트 컴포넌트 합성
으로 제목을 정했다. 이번에도 영어로 주요 내용을 모아봤다.
<select id="cars" name="cars">
<option value="audi">Audi</option>
<option value="mercedes">Mercedes</option>
</select>
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>
<Tabs>
<TabsList>
<CustomTabComponent />
// etc ...
<ToolTip message="cool">
<Tab>Second</Tab>
</ToolTip>
</TabsList>
// etc ...
<AnotherCustomThingHere />
<Thing>
<TabPanel>// etc ..</TabPanel>
</Thing>
//...
</Tabs>
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.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”?
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
}
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.export const Tab = ({ children }) => {
const tabAttributes = useTab()
return <div {...tabAttributes}>{children}</div>
}
export const TabPanel = ({ children }) => {
const tabPanelAttributes = useTabPanel()
return <div {...tabPanelAttributes}>{children}</div>
}
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>
)
}
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>
)
}
Pressable
component used internally by a Button
and Link
.<ProductTabs />
that uses our Tab component underneath.