This question was asked in Atlassian’s principal engineer interview (P60) where it was asked to Build a Tab component with some variations like lazy loading content when Tab Panel is visible.

We will solve this problem with the two different approaches.
- using context
- using store (normal state)
Using context
We will create a TabProvider component and manage the state within that, this will give the flexibility to use the state within any component and accordingly listen to the tab state or update it.
TabProvider
import { createContext, useState, useContext } from "react";
const TabContext = createContext();
const TabProvider = ({ children }) => {
const [activeTab, setActiveTab] = useState(null);
return (
<TabContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabContext.Provider>
);
};
const useTabState = () => {
const context = useContext(TabContext);
if (!context) throw new Error("useTabState must be used within TabProvider");
return context;
};
export { TabProvider, useTabState };
Now we can create tab and tab-panel by wrapping the components inside the TabProvider.
export default function Example() {
return (
<div className="App">
<TabProvider>
<TabComponent />
</TabProvider>
</div>
);
}
const TabComponent = () => {
const { setActiveTab, activeTab } = useTabState();
useEffect(() => {
setActiveTab("share");
}, []);
return (
<div className="tab-wrapper">
<div className="tab">
<button
className={activeTab === "share" ? "active" : ""}
aria-role="tab"
id="share"
onClick={() => {
setActiveTab("share");
}}
>
Share
</button>
<button
className={activeTab === "publish" ? "active" : ""}
aria-role="tab"
id="publish"
onClick={() => {
setActiveTab("publish");
}}
>
Publish
</button>
</div>
<div className="tab-panel">
{activeTab === "share" && (
<div aria-role="tabpanel" id="share">
Share
</div>
)}
{activeTab === "publish" && (
<div aria-role="tabpanel" id="publish">
<Suspense fallback={<Loading />}>
<PublishHandler />
</Suspense>
</div>
)}
</div>
</div>
);
};
Style
.App {
font-family: sans-serif;
text-align: center;
}
.tab-wrapper {
display: flex;
gap: 10px;
flex-direction: column;
}
.tab {
display: flex;
gap: 10px;
}
.tab button {
cursor: pointer;
border: none;
outline: none;
padding: 10px;
box-shadow: 0px 1px 1px gray;
}
.tab button.active {
background: greenyellow;
}
.tab-panel {
border: 1px solid;
min-height: 100px;
padding: 10px;
box-shadow: 0px 1px 1px gray;
}
Testcase
To test the lazy loading we will create a separate component and then lazy load it after a delay using React.lazy to load and Suspense to show fallback.
Demo.js
External component that will be lazy loaded.
import { useEffect } from "react";
export default function PublishHandler() {
useEffect(() => {
return () => {
console.log("unmounting");
};
}, []);
return <h1>Publish Handler</h1>;
}
Complete code with lazy-loading
import { useEffect, lazy, Suspense } from "react";
import "./styles.css";
import { TabProvider, useTabState } from "./Tab";
const PublishHandler = lazy(() => delayForDemo(import("./Demo.js")));
export default function Example() {
return (
<div className="App">
<TabProvider>
<TabComponent />
</TabProvider>
</div>
);
}
const TabComponent = () => {
const { setActiveTab, activeTab } = useTabState();
useEffect(() => {
setActiveTab("share");
}, []);
return (
<div className="tab-wrapper">
<div className="tab">
<button
className={activeTab === "share" ? "active" : ""}
aria-role="tab"
id="share"
onClick={() => {
setActiveTab("share");
}}
>
Share
</button>
<button
className={activeTab === "publish" ? "active" : ""}
aria-role="tab"
id="publish"
onClick={() => {
setActiveTab("publish");
}}
>
Publish
</button>
</div>
<div className="tab-panel">
{activeTab === "share" && (
<div aria-role="tabpanel" id="share">
Share
</div>
)}
{activeTab === "publish" && (
<div aria-role="tabpanel" id="publish">
<Suspense fallback={<Loading />}>
<PublishHandler />
</Suspense>
</div>
)}
</div>
</div>
);
};
function Loading() {
return (
<p>
<i>Loading...</i>
</p>
);
}
function delayForDemo(promise) {
return new Promise((resolve) => {
setTimeout(resolve, 2000);
}).then(() => promise);
}

Using store
If you notice in the first method, we are doing lots of static code processing, which makes it hard to extend as if we have to add new tab then we will have to write all the code to handle the new tab and tab-panel rendering.
An optimized way be to provide the abstraction by creating a common component Tab and TabPanel that will extract all the logic of hiding and showing the tab content when tab is active.
Also we don’t need to use the context until and unless the child components need to handle the tab state. We can just use the normal state and maintain the tabs efficiently.
Abstracted Tab and TabPanel component with custom hook useTabStore
We will create a custom hook useTabStore that will return the store, the state methods to maintain the tab state and listen to its data.
import { useEffect, useState } from "react";
const useTabStore = () => {
const [activeTab, setActiveTab] = useState(null);
return {
setActiveTab,
activeTab,
};
};
const Tab = ({ id, label, store, defaultActive }) => {
const { setActiveTab, activeTab } = store;
useEffect(() => {
if (defaultActive) {
setActiveTab(id);
}
}, []);
return (
<button
aria-role="tab"
onClick={() => {
setActiveTab(id);
}}
className={activeTab === id ? "active" : ""}
>
{label}
</button>
);
};
const TabPanel = ({ tabId, store, unMountOnHide, children }) => {
const { activeTab } = store;
if (unMountOnHide && activeTab !== tabId) {
return null;
}
const hidden = !unMountOnHide && activeTab !== tabId;
const hiddenProps = hidden ? { hidden: true } : {};
return (
<div aria-role="tabpanel" className={hidden ? "hide" : ""} {...hiddenProps}>
{children}
</div>
);
};
export { useTabStore, Tab, TabPanel };
Here we have added an additional prop unMountOnHide on the tab panel that decides if the child component is removed from the DOM tree when tab panel hides or not. It could be useful in some cases.
export default function Example() {
const store = useTabStore();
return (
<div className="App">
<div className="tab-wrapper">
<div className="tab">
<Tab store={store} id="share" label="share" defaultActive />
<Tab store={store} id="publish" label="publish" />
</div>
<div className="tab-panel">
<TabPanel tabId={"share"} store={store}>
share
</TabPanel>
<TabPanel tabId={"publish"} store={store}>
<Suspense fallback={<Loading />}>
<PublishHandler />
</Suspense>
</TabPanel>
</div>
</div>
</div>
);
}
Notice, how clean the code looks and it easier to read and maintain. The tab with prop defaultActive is the active tab.
Style
.App {
font-family: sans-serif;
text-align: center;
}
.tab-wrapper {
display: flex;
gap: 10px;
flex-direction: column;
}
.tab {
display: flex;
gap: 10px;
}
.tab button {
cursor: pointer;
border: none;
outline: none;
padding: 10px;
box-shadow: 0px 1px 1px gray;
}
.tab button.active {
background: greenyellow;
}
.tab-panel {
border: 1px solid;
min-height: 100px;
padding: 10px;
box-shadow: 0px 1px 1px gray;
}
Testcase
To test the lazy loading we will create a separate component and then lazy load it after a delay using React.lazy to load and Suspense to show fallback.
Demo.js
External component that will be lazy loaded.
import { useEffect } from "react";
export default function PublishHandler() {
useEffect(() => {
return () => {
console.log("unmounting");
};
}, []);
return <h1>Publish Handler</h1>;
}
Complete code with lazy-loading
import { lazy, Suspense } from "react";
import "./styles.css";
import { useTabStore, Tab, TabPanel } from "./Tab2";
const PublishHandler = lazy(() => delayForDemo(import("./Demo.js")));
export default function Example() {
const store = useTabStore();
return (
<div className="App">
<div className="tab-wrapper">
<div className="tab">
<Tab store={store} id="share" label="share" defaultActive />
<Tab store={store} id="publish" label="publish" />
</div>
<div className="tab-panel">
<TabPanel tabId={"share"} store={store}>
share
</TabPanel>
<TabPanel tabId={"publish"} store={store}>
<Suspense fallback={<Loading />}>
<PublishHandler />
</Suspense>
</TabPanel>
</div>
</div>
</div>
);
}
function Loading() {
return (
<p>
<i>Loading...</i>
</p>
);
}
function delayForDemo(promise) {
return new Promise((resolve) => {
setTimeout(resolve, 2000);
}).then(() => promise);
}

When you set the prop unMountOnHide on TabPanel you will notice that unmounting is being console logged from PublishHandler component.