Advanced compositional React with useContext, useRef, and useState
Let’s learn how to use React 16.8’s hooks API to create a clean, compositional UI component. This article assumes you’re familiar with hooks, context, and component composition already, and you’re looking for elegant solutions to implement them.
I’ll begin with our desired end result: a “tabs” component that keeps track of which tab is open and displays the content of the selected tab. We want our API to be clean, concise, and compositional.
<Tabs>
<Panel>
<Tab>Panel 1</Tab>
<Content>Panel 1 content</Content>
</Panel>
<Panel>
<Tab>Panel 2</Tab>
<Content>Panel 2 content</Content>
</Panel>
</Tabs>
To build out these components, we’ll need to:
- Keep track of the currently selected tab in the Tabs component with state, and provide the currently selected tab and a callback to change it using context
- Create a unique identifier for the given tab in the Panel component
- Consume the context of the currently selected tab and the callback to our Tab component, so that it can (a) render conditional styles based on whether it is selected, and (b) change the selected tab when it is clicked
- Consume the context of the currently selected tab in our Content component, so it knows whether it is selected and should render its content
- Render all the Tab components together and all the Content components together
Let’s write some boilerplate first, creating the presentational aspects of these components:
const Tabs = ({ children }) => (
<div className="tabs">{children}</div>
)const Panel = ({ children }) => childrenconst Tab = ({ children }) => (
<button className="tab">{children}</button>
)const Content = ({ children }) => (
<div className="content">{children}</div>
)
You can see that our components aren’t in the right position in the DOM — we want all the tabs to appear first in an unordered list, followed by all the content elements.
Using ReactDOM.createPortal
, we can move the components into the right spot — but how will we know where to put them? This is where a ref to the DOM element will come in handy:
const tabListElementContext = createContext()const Tabs = ({ children }) => {
const tabListElement = useRef() return (
<div className="tabs">
<ul className="tab-list" ref={tabListElement} />
<tabListElementContext.Provider
value={tabListElement.current}
>
{tabListElement.current ? children : null}
</tabListElementContext.Provider>
</div>
)
}const Tab = ({ children }) => {
const tabListElement = useContext(tabListElementContext) return createPortal(
<li className="tab">
<button>{children}</button>
</li>,
tabListElement
)
}
Here we’ve created a context, and the value of the context is a ref to the DOM element we want to render the tabs in. That way, any child elements (the Tab element specifically) can get a reference to the container that they should be rendered in.
We only render children in the Tabs component if the ref has a value (in current
). This is because on the first render, the ref won’t be set yet since the ul
element hasn’t been created. We don’t want to bother trying to render the tab list items until there is a container for them to be put into.
However, it looks like we have a problem. tabListElement.current
will be undefined on the first pass, and the Tabs component won’t re-render again when this value changes — because a mutation to a ref doesn’t cause a re-render like a change to state does.
That’s a simple fix — instead of using a ref, we’ll just use state. Surprisingly, you can pass a state callback right into the ref prop, no problem!
const tabListElementContext = createContext()const Tabs = ({ children }) => {
const [tabListElement, setTabListElement] = useState(null) return (
<div className="tabs">
<ul className="tab-list" ref={setTabListElement} />
<tabListElementContext.Provider value={tabListElement}>
{tabListElement ? children : null}
</tabListElementContext.Provider>
</div>
)
}
Next, let’s write some logic in the Tabs component to keep track of the currently selected tab.
const tabListElementContext = createContext()
const selectedPanelContext = createContext()
const setSelectedPanelContext = createContext()const Tabs = ({ children }) => {
const [tabListElement, setTabListElement] = useState(null)
const [selectedPanel, setSelectedPanel] = useState(null) return (
<div className="tabs">
<ul className="tab-list" ref={setTabListElement} />
<tabListElementContext.Provider value={tabListElement}>
<setSelectedPanelContext.Provider value={setSelectedPanel}>
<selectedPanelContext.Provider value={selectedPanel}>
{tabListElement ? children : null}
</selectedPanelContext.Provider>
</setSelectedPanelContext.Provider>
</tabListElementContext.Provider>
</div>
)
}
Oof, that’s a lot of providers. We could use a single context object and store all three of these values in one single object. Alternatively, it can be useful to write or install a utility to compose multiple providers and make the code a little less verbose. For now, let’s stick with this model, just so we’re not obfuscating what’s going on behind the scenes.
We’re now providing a way to set the selected tab and get the currently selected tab in our child components.
Let’s jump into the Tab component, which will need to be able to set the currently selected tab when the button is clicked.
const Tab = ({ children }) => {
const tabListElement = useContext(tabListElementContext)
const setSelectedPanel = useContext(setSelectedPanelContext) const handleClick = useCallback(() => {}, [setSelectedPanel]) return createPortal(
<li className="tab">
<button onClick={handleClick}>{children}</button>
</li>,
tabListElement
)
}
Hm… what value should we set the selected panel to, anyways? We don’t have any way to uniquely identify the panel.
We could have the Panel component take in a prop like panelId
— but that seems like adding unnecessary complexity to our API. We could try to generate a unique id the first time the component mounts…
There’s a super simple way to accomplish this, and it doesn’t involve generating a real identifier — just a unique reference. While it seems odd at first, let’s try making a ref inside the Panel component and using that. The ref doesn’t need to actually have a value — it just needs to serve as an object that was uniquely created once for this particular instance of the Panel component. So let’s create another ref and another context.
const panelContext = createContext()const Panel = ({ children }) => {
const panel = useRef() return (
<panelContext.Provider value={panel}>
{children}
</panelContext.Provider>
)
}const Tab = ({ children }) => {
const tabListElement = useContext(tabListElementContext)
const setSelectedPanel = useContext(setSelectedPanelContext)
const panel = useContext(panelContext) const handleClick = useCallback(
() => setSelectedPanel(panel),
[setSelectedPanel, panel]
) return createPortal(
<li className="tab">
<button onClick={handleClick}>{children}</button>
</li>,
tabListElement
)
}
Notice how we are using a ref without ever even bothering to read or write the current
property. Think of the ref like it’s this
in a class component — it just represents this particular instance of the Panel component.
We’ve gotten through the hard parts. Next, let’s make our Content component render conditionally based on the selected tab.
const Content = ({ children }) => {
const selectedPanel = useContext(selectedPanelContext)
const panel = useContext(panelContext) return selectedPanel === panel
? <div className="content">{children}</div>
: null
}
Easy enough. We can go back and do the same to our Tab component, consuming the selected panel as well, so we can add a conditional class that indicates the tab is selected.
const Tab = ({ children }) => {
const tabListElement = useContext(tabListElementContext)
const setSelectedPanel = useContext(setSelectedPanelContext)
const selectedPanel = useContext(selectedPanelContext)
const panel = useContext(panelContext) const handleClick = useCallback(
() => setSelectedPanel(panel),
[setSelectedPanel, panel]
) const classNames = ['tab'] if (selectedPanel === panel) {
classNames.push('active')
} return createPortal(
<li className={classNames.join(' ')}>
<button onClick={handleClick}>{children}</button>
</li>,
tabListElement
)
}
At this point, everything is working great! We have a fully functional tabbed interface according to the initial spec.
We should add one more requirement though — currently our Tabs component doesn’t offer any way to indicate its initially selected tab.
What value could we pass for the selected tab? After all, we aren’t using string identifiers, we are using references created in the Panel component.
Let’s refactor to setting the current value of the ref to an empty object — just to create a new reference. We’ll switch from using the ref itself to using its value to determine the identity of the Panel. There’ll still be one object reference created per instance of the component.
React has a concept of “forwarded refs,” which allow the owner component to pass in a ref and have direct access to it in the Panel component.
We’ll create our own ref in the Panel component, set to a new empty object that uniquely identifies our Panel. Then, we’ll use the forwarded ref to “notify” the parent component so it has access to the ref. From there, it can pass the same ref value (the empty object) into the Tabs component to indicate which Panel should be open.
To do this, I’m going to pull in a utility library that’ll make dealing with forwarded refs a little easier — especially to iron out the inconsistencies. For example, as we proved above with setTabListElement
, the ref could be a ref object with current or a callback. We also need to handle the case where no ref is provided at all.
import React, { forwardRef } from 'react'
import useForwardedRef from '@bedrock-layout/use-forwarded-ref'const Panel = forwardRef(({ children }, ref) => {
const panelRef = useForwardedRef(ref) if (!panelRef.current) {
panelRef.current = {}
} return (
<panelContext.Provider value={panelRef.current}>
{children}
</panelContext.Provider>
)
})
The ref’s current
property will be set, and the parent’s ref’s current
property will be set to the same object reference — the useForwardedRef
function handles this. It’s only set on the first render. Now, the parent component that renders Tabs can pass the same ref value into the Tabs component to indicate which Panel is active.
const tabListElementContext = createContext()
const selectedPanelContext = createContext()
const setSelectedPanelContext = createContext()const Tabs = ({ children, initialPanelRef = null }) => {
const [tabListElement, setTabListElement] = useState(null)
const [selectedPanel, setSelectedPanel] =
useState(initialPanelRef) useEffect(
() => setSelectedPanel(initialPanelRef),
[setSelectedPanel, initialPanelRef]
) return (
<div className="tabs">
<ul className="tab-list" ref={setTabListElement} />
<tabListElementContext.Provider value={tabListElement}>
<setSelectedPanelContext.Provider value={setSelectedPanel}>
<selectedPanelContext.Provider value={selectedPanel}>
{tabListElement ? children : null}
</selectedPanelContext.Provider>
</setSelectedPanelContext.Provider>
</tabListElementContext.Provider>
</div>
)
}
When we render the Tabs component, we’ll create the ref for the selected panel there:
const [firstPanelRef, setFirstPanelRef] = useState()<Tabs initialPanelRef={firstPanelRef}>
<Panel ref={setFirstPanelRef}>
<Tab>Panel 1</Tab>
<Content>Panel 1 content</Content>
</Panel>
<Panel>
<Tab>Panel 2</Tab>
<Content>Panel 2 content</Content>
</Panel>
</Tabs>
Alright! Let’s bring it all together. Here’s the end result: