Tabs component with React Hooks

In this tutorial, we will create custom reusable Tabs component using best practices and no external libraries.

In the end, we should be able to implement our Tabs component in the following way:

<Tabs>
    <Tab id={tabs.case1}>
        Tab 1 content
    </Tab>
    <Tab id={tabs.case2}>
        Tab 2 content
    </Tab>
    <Tab id={tabs.case3}>
        Tab 3 content
    </Tab>
</Tabs>

Create the Context

Create a fresh React app:

npx create-react-app tabs-component-react 

We will use the Context API, in order to avoid prop drilling and make the Tabs component’s API more intuitive. It might seem like overkill but if the complexity increases, this approach will pay off. Let’s create our TabsContext.js file in the src/context folder

import React, {useContext, useState} from 'react';

export const TabsContext = React.createContext();

When using the Context API, we should also have a Context Provider to wrap our consumer components. This is a very good candidate for Higher Order Component(HOC), which will allow us to connect our components to the shared state. Note that, the only value that our context will contain is the currentTab and the function for setting it. If the logic was more complex, it would be better to use useReducer API. Here is our HOC:

export const withTabs = (Component) => ({ children, ...props }) => {
    const [currentTab, setCurrentTab] = useState();

    return (
        <TabsContext.Provider value={{currentTab, setCurrentTab}}>
            <Component {...props}>
                {children}
            </Component>
        </TabsContext.Provider>
    );
};

Lastly, we will also add a custom hook, which will allow us to access our shared state from any component, as long as it’s wrapped by our newly created Higher Order Component:

export const useTabs = () => {
    const {currentTab, setCurrentTab} = useContext(TabsContext);

    if(!TabsContext) {
        throw new Error('useTabs should be used inside TabsProvider')
    }

    return {
        currentTab, setCurrentTab
    }
}

Tabs component

We will design our Tabs component in a way that, it takes all the tabs as children, but displays only the active one in the UI.

import React, {useEffect} from 'react';
import {useTabs} from "../context/TabsContext";
import './tabs.css';

export const Tabs = function({ children, tabs, defaultTab, onTabSelect, className, ...props }) {
    const { currentTab, setCurrentTab } = useTabs();

    useEffect(() => {
        setCurrentTab(defaultTab)
    }, [setCurrentTab, defaultTab])

    return (

        <div className={`tabs ${className}`} {...props} >
            <ul className="tabs-header">
                {Object.values(tabs).map((tabValue) => (
                    <li onClick={() => setCurrentTab(tabValue)} className={`${currentTab === tabValue ? 'active' : ''}`} key={tabValue} onClick={() => onTabSelect(tabValue)}> {tabValue} </li>
                ))}
            </ul>
            <div className="tabs-body">
                {
                    children &&
                    React.Children.map(children, (child) => {
                        if(child.type.name !== 'Tab') {
                            throw new Error('The child components should be of type Tab')
                        }
                        return child.props.id === currentTab ? child : null;
                    })
                }
            </div>
        </div>
    );
};

export const Tab = function({ children, ...props }) {
    return <section {...props}>{children}</section>
}

The component takes the following props:

  • tabs – the object will the tabs names. You will find more info about this in the next section
  • defaultTab – the tab that should be active by default
  • onTabSelect – here we pass the event handling for the tab change to the consumer component. So, the API becomes more flexible.
  • className – this class will be applied to the element containing all the tabs and allow custom stying. All the other props passed to the component will be applied to the same element if we want to add any specific attributes

First, inside useEffect we set the initial value of currentTab to equal the defaultTab value.

Below, we iterate trough the tabs object, which we receive as a prop, and display it’s values in our tab navigation.

The most import part of the code is how we iterate through all the children of the Tabs component, check if the child component is of type Tab, and then render only the Tab whose id value matches the currentTab value from our shared state.

At the bottom, you will find the definition for our Tab component. It’s a very simple component without any state, that just renders it’s content.

Here is its styling for our component. I’ve used pure CSS for the sake of simplicity, but feel free to use one of the many advanced styling tools for React:

.tabs {
    width:100%;
    min-height:100%;
}

.tabs ul.tabs-header {
    padding-left:0;
    list-style-type: none;
    margin-bottom:0;
    display:flex;
}
.tabs ul.tabs-header li{
    flex:1;
    text-align:center;
    display:inline-block;
    cursor:pointer;
    padding:1rem;
    border:1px solid #ccc;
    transition:0.3s ease-out;
}

.tabs ul.tabs-header li.active{
    background:#000;
    color:#fff;

}

.tabs ul.tabs-header li:not(:last-child){
    border-right:none;
}

.tabs .tabs-body {
    height:100%;
    text-align:center;
    padding: 3rem;
    border:1px solid #ccc;
    border-top:none;
}

Implementation

So far, it might have look like a lot of work for such a simple functionality, but the real beauty comes with the implementation:

import {useTabs, withTabs} from "./context/TabsContext";
import {Tabs, Tab} from "./components/Tabs";

const tabs = {
    firstTab: 'Tab 1',
    secondTab: 'Tab 2',
    thirdTab: 'Tab 3'
}
function Demo() {
  const { setCurrentTab } = useTabs();

  return (
    <div className="wrapper">
        <Tabs tabs={tabs} defaultTab={tabs.case1} onTabSelect={(tab) => setCurrentTab(tab)} className="custom-tab-container-class">
            <Tab id={tabs.firstTab}>
                Lorem ipsum dolor sit amet.
            </Tab>
            <Tab id={tabs.secondTab}>
                Ad alias dolore eos hic provident, quam quia tempore veritatis voluptatum.
            </Tab>
            <Tab id={tabs.thirdTab}>
               Aspernatur ducimus pariatur quo saepe sed, velit!
            </Tab>
        </Tabs>
    </div>
  );
}

export default withTabs(Demo);

Notice that we’ve wrapped our component in in our withTabs HOC at the bottom of the file. It also uses setCurrentTab from our shared state(context). I’ve added custom-tab-container-class class to Tabs, in order to show how we can add custom styling and adjust our Tabs component according to our needs. Here is the global css:

body {
  margin: 0;
  font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
}
.wrapper {
  margin: 0 auto;
  width: 500px;
  height:150px;
  display:flex;
  align-items: center;
  flex-direction: column;
}

.custom-tab-container-class .tabs-body{
  height:250px;
}

You can find the code in the Git repo, that I’ve created.

Enjoy!