Let's pick a simple example - a few tabs with a simple checkboxes inside. But, regardless all the simplicity let's make it a bit more complicated that it should, and then optimize the approach making it better and better every step.
Let's try Redux
, setState
, useState
and... the end would be... unexpected.
The App
Let's assume that our App
would be something like
function App() {
return (
<div className="App">
<TabHeader>
<Tab id={1}>One</Tab>
<Tab id={2}>Two</Tab>
<Tab id={3}>Three</Tab>
</TabHeader>
<TabContext>
<Tab id={1}>
Content of tab1 <CheckBox />
</Tab>
<Tab id={2}>
Content of tab2 <CheckBox />
</Tab>
<Tab id={3}>
Content of tab3 <CheckBox />
</Tab>
</TabContext>
</div>
);
}
I wish to have the first example as worse as possible, but I think this would be enough...
Redux
O Redux, the State of the State! You rule the single source of truth, dispatching the goods for all of us.
Of course using Redux to handle Tab component is a right decision, as long as Redux is always a right decision.
// TabHeader just renders everything inside
const TabHeader = ({ children }) => <section>{children}</section>;
// "Stateless/Dumb" Tab component
const TabImplementation = ({ children, onSelect }) => <div onClick={onSelect}>{children}</div>;
// All the logic is in TabContext
const TabContextImplementation = ({ children, selectedTab }) => (
<section>
{React.Children.map(children, (child) => (
// displaying only "selected" child
selectedTab === child.props.id
? child
: null
))}
</section>
)
// connecting Tab, providing a `onSelect` action
const Tab = connect(null, (dispatch, ownProps) => ({
onSelect: () => dispatch(selectTab(ownProps.id))
}))(TabImplementation);
// connecting TabContext, reading the `selectedTab` from the state
const TabContext = connect(state => ({
selectedTab: state.selectedTab
}))(TabContextImplementation);
That's actually quite simple example how Redux
could wire your components, and how cool it is.
But, you know, probably we don't need such complex code for such simple example. Let's optimize!
Component State
To use own component state we just need to remove bindings to redux, and rewire App
, making it a Stateful component.
class App extends React.Component {
state = {
selectedTab: 1
};
onSelect = (selectedTab) => this.setState({selectedTab});
render () {
return (
<div className="App">
<TabHeader>
<Tab id={1} onSelect={() => this.onSelect(1)}>One</Tab>
<Tab id={2} onSelect={() => this.onSelect(1)}>Two</Tab>
<Tab id={3} onSelect={() => this.onSelect(1)}>Three</Tab>
</TabHeader>
<TabContent selectedTab={this.state.selectedTab}>
<Tab id={1}>
Content of tab1 <CheckBox />
</Tab>
<Tab id={2}>
Content of tab2 <CheckBox />
</Tab>
<Tab id={3}>
Content of tab3 <CheckBox />
</Tab>
</TabContent>
</div>
);
}
}
That's all the state management we need. Could it be simpler?
Hooks
Would hooks
make it even better? Yes! They would! Even if difference is minimal it's huge from readability point of view.
const App = () => {
const [selectedTab, setSelected] = useState(1);
return (
<div className="App">
<TabHeader>
<Tab id={1} onSelect={useCallback(() => setSelected(1))}>One</Tab>
<Tab id={2} onSelect={useCallback(() => setSelected(1))}>Two</Tab>
<Tab id={3} onSelect={useCallback(() => setSelected(1))}>Three</Tab>
</TabHeader>
<TabContent selectedTab={selectedTab}>
<Tab id={1}>
Content of tab1 <CheckBox />
</Tab>
<Tab id={2}>
Content of tab2 <CheckBox />
</Tab>
<Tab id={3}>
Content of tab3 <CheckBox />
</Tab>
</TabContent>
</div>
);
}
We did it?
The difference between the first and the last example is ... different. Amount of code needed is almost the same, benefits are incomparable, readability is perfect in any case.
The only big difference is in "component-ization" - with local state and hooks the tab State
is local while with Redux it is global. It's nor bad, nor good, and both would serve you needs.
Did we forget something?
We did a great react job, but a poor html job. Attaching onClick
handlers to divs is a worst idea ever - it's absolutely not accessible.
Is there a way to handle a "state", and make application "accessible" in the same time?
HTML State
Let me first show you the code, then I'll explain how it works
const TabHeader = ({ children, group }) => (
<>
{React.Children.map(children, (child, index) => (
// we will store "state" in a radio-button
<input
class="hidden-input tab-control-input"
defaultChecked={index === 0}
name={group}
type="radio"
id={`control-${child.props.controls}`}
/>
))}
<nav>{children}</nav>
</>
);
const Tab = ({ children, controls }) => (
// Tabs are controlled not via `div`, `button` or `a`
// Tabs are controlled via `LABEL` attached to input, and to the tab itself
<label htmlFor={`control-${controls}`} aria-controls={controls}>
{children}
</label>
);
const TabContent = ({ children, group }) => (
<section className="tabs">
{React.Children.map(children, (child, index) => (
<section class="tab-section" id={child.props.id}>{child.props.children}</section>
))}
</section>
);
const CheckBox = () => <input type="checkbox" />;
const App = () => (
<div className="App">
<TabHeader group="tabs">
<Tab controls="tab1">One</Tab>
<Tab controls="tab2">Two</Tab>
<Tab controls="tab3">Three</Tab>
</TabHeader>
<TabContent>
<Tab id="tab1">
Content of tab1 <CheckBox />
</Tab>
<Tab id="tab2">
Content of tab2 <CheckBox />
</Tab>
<Tab id="tab3">
Content of tab3 <CheckBox />
</Tab>
</TabContent>
</div>
);
That's all, there is NO STATE MANAGEMENT at all, and it works much better than before. Only redio-buttons
with labels
and tabs
, not quite related to them... I am not sure how it works - but it works.
Here is the proof - link to the sandbox - https://codesandbox.io/s/romantic-http-h6cgj
To be more concrete - it works as well as reach-ui tabs - try it, it's the same. It's the same true accessible experience Reach is so passionate about.
But reach-ui
has to handle keyboard events and focus management to make tabs work as they should - only the active
tab-header is focusable, and you might change tabs by pressing Left
/Right
.
You may not belive - it my example it would work exactly the same.
The secret
The secret is in CSS - every input
is visually hidden. So it exists, it's focusable, but invisible. And everything else uses input
state.
// input 1 is checked? Then tab 1 should be visible.
.tab-control-input:checked:nth-of-type(1) ~ .tabs .tab-section:nth-of-type(1){
display: block;
}
// and label should be bold if corresponding radio button is active
.tab-control-input:checked:nth-of-type(1) ~ nav label:nth-of-type(1){
font-weight: 600;
}
// and focus ring should be teleported to a label
.tab-control-input:focus:nth-of-type(1) ~ nav label:nth-of-type(1) {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
}
There is a limitation with structure - the ~
operator needs input
to be on one side, and label
+ tab
on another. That means that all inputs would be on the left and all tabs would be on the right. As a result something like this
.tab-control-input:checked ~ .tabs .tab-section {
display: block;
}
would display all .tabs
for any input
checked, as long as they all match the condition.
In this example I've used :nth-of-type(N)
, but it's better to generate CSS on the fly (CSS-in-JS), with more unique selectors, like #category-women-clothing:checked ~ div nav label[for="category-women-clothing"]
(code snippet from theurge), or input-0-0-1
like in Reach.
Conclusion
So, SURPRISE! HTML could be your State Management, and you will get more semantic and MUCH more accessible result out of the box. Play with it for a while :)
You know - use the platform :) You might miss something.
Top comments (2)
This just doesn’t feel right. I’m uncomfortable with CSS Houdini-like workarounds in the UI. But hey if it’s working for you that’s great! I just feel as though this wont work in a lot of cases
You are absolutely right - this stuff is very fragile and might work even slower than js-powered as long as there is more HTML rendered.