loading...
Cover image for Behold the HTML State

Behold the HTML State

thekashey profile image Anton Korzunov ・5 min read

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.

TL;DR https://codesandbox.io/s/romantic-http-h6cgj

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.

Posted on by:

Discussion

markdown guide
 

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.