Introducing Compound Components0:00
The composable components that we've looked at in the previous episodes were just glorified starred HTML elements. There wasn't any logic shared between the components. In this episode, we're going to take a look at compound components, which are also composable nestable components, but that do share implicit state and logic between each other. All right, so for this one we're going to generate
Generating Tabs Component0:17
and logic between each other. All right, so for this one we're going to generate yet another chat CN UI component. And this time what I'm going to generate is the tabs component. All right, so we got a new tabs.tsx file and if we take a quick look, it does look a lot like the table components that we've looked at in the previous episode, except that it seems to be using the tabs primitive.
that we've looked at in the previous episode, except that it seems to be using the tabs primitive for each component, which comes from Rex UI react tabs. Let's grab the code for an example tabs component from the chat and UI docs. Alright, so here we have an account and password tab switcher, and let's take a look at the code. We don't necessarily need the form fields here, but let's take a look at the anatomy.
We don't necessarily need the form fields here, but let's take a look at the anatomy. So we have a tabs wrapper, a tabs list, and then tabs content. And here we have a card. So this we don't really care about, but the main parts basically are the tabs wrapper, the tabs list with triggers, and then the tabs content. So I'll copy all of it and we'll put this in the welcome.
So I'll copy all of it and we'll put this in the welcome .tsx page like we did in the previous lesson. But I will remove the card and form field imports and instead we'll just have a <p> tag for each. This is the account tab panel and same deal for the other one. Actually, I'll copy this <p> tag and change accounts to password. Alright, so we got some nice concise composable
and change accounts to password. Alright, so we got some nice concise composable components and here it is. We have our two tabs that we can switch to and from. Again, I wanna add some padding to it. So I will wrap the whole thing in p-8 like so just gives us some breathing room. And now let's take a look at the key difference between the composable components we've looked at like for the table components.
Understanding Implicit State2:06
between the composable components we've looked at like for the Table components and something like these compound components for the Tabs component on paper. Looking at the component primitives that we have, it does feel very similar to what we had for the Table with the table heading, table body, et cetera. But the main difference lies in the implicit logic that is baked into this component. So if I inspect one of the toggles for example,
that is baked into this component. So if I inspect one of the toggles for example, and I switch tabs back and forth, you can see that we have these elements like aria-selected, data, states that are being updated on the fly as I change the selection. And if I inspect the tab panel here, which is rather the wrapping div here, once again as I toggle the tabs, you can see the data states active and inactive and the hidden attributes.
as I toggle the tabs, you can see the data states active and inactive and the hidden attributes being toggled on and off. And so for these attributes to change in the DOM, we need some sort of state somewhere in React to take care of these changes. And when we look at the components or the primitives that we've used, we don't see any of these states. It's hidden away from us
we don't see any of these states. It's hidden away from us and it's sort of implicitly handled within these compound components. The army hints that we get that there is some sort of connection between the components is these value props. Here we have value, account, and password for the tab triggers, the tab panels, and also the default value here. And then it looks like all the logic
and also the default value here. And then it looks like all the logic and the state management is hidden away from us and handled for us. And well this is exactly the purpose of compound components. So I've showed you that when we change tabs, some elements are being updated in the dump, but there is also some UX and keyboard navigation backed in these components. So if I tab inside one of the tabs,
and keyboard navigation backed in these components. So if I tab inside one of the tabs, I can move the arrows left and right or even just right and it's going to cycle through the possible tabs and it's never going to leave the tabs. So this is super practical and this is an accessibility pattern called the rovingTabIndex. And you can see that whenever I toggle one or the other tab with the arrow keys,
And you can see that whenever I toggle one or the other tab with the arrow keys, the panel is being updated and if I press tab, I will tab out of the tabs and onto the next focusable element. While in this page there isn't any, remember all the chat CN UI tabs components are using the redx UI React tabs. So let's go take a look at that on the redx website, we'll go to primitives
So let's go take a look at that on the redx website, we'll go to primitives and let's go find the tabs component on the sidebar here. So we've got the example like we've looked at in chat and again I can navigate with the arrows and if you look at the features, it's going to give us more information about all the accessibility and usability that it brings in. And so basically what chat and UI is doing is taking these components from redx UI.
And so basically what chat and UI is doing is taking these components from Redx UI and then providing a wrapper interface that feels consistent with the other SHA CN UI components. Redx UI is exporting tabs, route tabs, trigger tabs content, which is a common pattern in compound components. But then SHA CN UI will take these tabs primitive list and re-export it as a tabs list and the same tabs trigger for tabs, primitive trigger and so on.
Building Tabs From Scratch5:12
and the same tabs trigger for tabs, primitive trigger and so on. And so with this subtle change here, you end up with a component API that feels very familiar and consistent with the other shati and UI components like we've seen with the table components and many, many other. Alright, what we're going to do now, as you probably guessed, is build our own compound component from scratch.
as you probably guessed, is build our own compound component from scratch. Once again, we're not going to recreate a feature complete version of rex-ui tabs, but just enough to understand how compound component works and how you can wire up the implicit state between the components. And if you're curious about the rex-ui implementation, go ahead and check the view source link from the documentation and then in these tabs that TSX component,
go ahead and check the view source link from the documentation and then in these tabs that TSX component, you will find all the goodness that makes up the tabs primitives. And as you can see there is about 300 lines of code and it's pretty advanced. Alright? And off we go in the components UI directory, I will create my dashTabs.tsx, that's TSX. And so we'll create a minimal set of components, uh, just what we need to make it work.
And so we'll create a minimal set of components, uh, just what we need to make it work. We'll definitely want a Tabs which is going to be the root component that wraps all the functionality. So that's very important. And then we'll have a TabTrigger component, which is the button that you click to make one tab active. And then let's save a TabPanel. We could go in a lot more details, but I think that's enough to understand the concepts that go.
We could go in a lot more details, but I think that's enough to understand the concepts that go around compound components. And the last thing that we need is this implicit state management that sort of happens within these components. There's possibly more than one way to make things happen, but a pretty common pattern to wire up compound components is to use the React Context API.
to wire up compound components is to use the React context API. And so that's exactly what we're going to do here. Let's start by creating a tabsContext, const tabsContext equals createContext which we need to import from React. And so a context needs a default value and typically this is set to null and then we need to think of what sort of states we want to share between the tabs. So I think that the only piece of state that we need
to share between the tabs. So I think that the only piece of state that we need for these tabs to work is some signal to determine which tab is active, so maybe an active string and then we can match it with the different tabs. And whenever we want to toggle a tab to be active, we set the active state to that string. Let's try that. So let's create a Tabs component, function tabs and yes it's going to receive children. And here this is the root component of our Tabs.
tabs and yes it's going to receive children. And here this is the root component of our Tabs is where we are going to define the states. So const, yeah, let's go with that activeTab and setActiveTab. And we are going to use useState from React. And basically this Tabs component is going to provide the state through the context. So we have a Context component here that's been created and we can return, maybe we'll have a wrapping div here
So we have a Context component here that's been created and we can return, maybe we'll have a wrapping div here and then we will use these TabsContext. You can see copilot trying to use the old Provider syntax, but we can just have TabsContext since React 19. And inside this TabsContext we can render the children, which is where we'll compose our other compound components TabsContext because it is a context provider is going to want a value.
tabs context because it is a context provider is going to want a value. And so here is where we can pass an object with both the active tab and setActiveTab, which will effectively make this state available through the context. Okay, I need to fix the typo here. Return and what is wrong is the comma. Yep. And so you can see that TypeScript is trying to tell me
Return and what is wrong is the comma. Yep. And so you can see that TypeScript is trying to tell me that whatever we're passing doesn't match. No. So up here we can specify in angle brackets the type of the state that we want. We want an object that has an activeTab, which is not a number but a string. And then it has setActiveTab. And if you've watched my React course, you know we can use here dispatch.
And if you've watched my React course, you know we can use the dispatch setState action sets to a string. These are types from React, but now that we've done this, null is not going to be accepted because it doesn't match this. So the context shape can be either this object or null because it starts as null. Okay? And now, now understand why Copilot try to set activeTab to number.
Okay? And now, now understand why copilot tries to set the active tab to number. It's because in the useState here we had 0, but we should have an empty string instead. Alright? And now things are starting to look good. Okay, so we have our root component, the Tabs component, and now we're going to build the tabTrigger. So I'll scroll down a bit and let's create a function tabTrigger. And that's basically just going to be a button.
and let's create a function tabTrigger. And that's basically just going to be a button. Let's call it trigger for now. And what's important to note is here this tabTrigger is a sub component, a compound component. And we never want to use the tabTrigger anywhere when it's not in the context of our tabs. So we are not going to export this tabTrigger, but we are going to make it available from the main Tabs component.
but we are going to make it available from the MainTabs component. So what I'm going to do is export the tabs and before I hit the naming collision, let's call it myTabs, this also mirrors the name of the file and I'm going to export this. And now for the tabTrigger, which I can call myTabTrigger, I am not going to export this, but what I can do down here is make it accessible from the tabs
to export this, but what I can do down here is make it accessible from the Tabs component with my tabs.trigger equals my tabTrigger. So what this line does is make my tabTrigger this component available as a property of the MyTabs component, which is this one. So let's take a look at how that works, uh, by going in our welcome page and let's turn the parentMode div into a flex container.
by going in our welcome page and let's turn the parent mode div into a flex container flex call and gap 20 to give some space between the elements. And here I'm going to import the MyTabs component that we've just created. This is a container component, so it's gonna want some children. And here is where I can use the myTabs . and you can see the trigger component available here.
And here is where I can use the myTabs dot and you can see the trigger component available here. And for now we'll have it self-closing without anything inside of it. And you can see our trigger being rendered here. So that's not very useful for now, but we can enhance our tab trigger a little bit by giving it a label prop, which is a string and we can output the label here. So now I need to update the myTabs that trigger component.
and we can output the label here. So now I need to update the my tabs that trigger a component to give it this label prop that it wants and we'll call this one settings and let's duplicate this and have another one called billing. Alright? And it's looking terrible. But here are the two triggers. Maybe we can wrap these in a flex with a gap of two. And what I'm doing here is the equivalent of these tabs list.
And what I'm doing here is the equivalent of these tabs list. You can see there's some classes for layout purposes. So you can imagine we would have a myTabs that trigger container or whatever and okay that's a little bit better, but they still aren't doing anything and we need to wire them to work with the actual states from the context provider. So if we look at the CN UI implementation, you can see
with the actual states from the context provider. So if we look at the CN UI implementation, you can see that there is this value prop that is used for the triggers, the content, and then the default value. So why don't we mirror this functionality? So instead of label, I will have value here and I don't necessarily always want the value string to be output in the button because this might be lowercase. So why don't we get the rest of the props? And this is going to be a component props
So why don't we get the rest of the props? And this is going to be a component props for the button element. And so here, instead of passing the label inside, I can spread all the props to the button like so. And so now I need to update the component in the welcome page. So we'll switch dues to value and maybe let's make dues lowercase. And we are going to have a different label here with settings.
And we are going to have a different label here with settings. And just to make it super clear, it could be mySettings, the whole idea is we can decouple the text of the button from the value string. Alright, our buttons are still working and let's give these buttons some minimal styles. So bg-slate-200, px-4, py-2 rounded text-slate-800 font-semi-bold. How does this look? Eh, that'll do.
Wiring Triggers With Context13:16
rounded text slate 800 font semi bold. How does this look? Eh, that'll do. Okay, and now we can finally try to consume the context that is provided in the myTabs component. So we have a context value here and we can access it in the tab trigger by doing this. const context = use function from React, which allows us to access a context based on its name and it's the tabContext here.
to access a context based on its name and it's the tab context here. Now this context will only be available if we are inside a MyTabs component. And just to make sure we are, we can check that we actually do have access to the context and if we don't, we are going to throw an error to indicate that we forgot the wrapping parent most root component, which is called MyTabs. That's perfect. So below this line we can assume
which is called my tabs. That's perfect. So below this line we can assume that we have access to the context so we can destructure the various parts of this context. And I should have auto complete here activeTab and setActiveTab. And so now I can finally wire up my trigger with the state. So I'll use the setActiveTab here to change the value of activeTab to whatever we passing as a value to the component.
of active tab to whatever we passing as a value to the component. So when we click on this trigger, the active tab is going to be settings. And then when we click on this one, the active tab is going to be billing. So on the button I will add an onClick and set the active tab to value, which is that string that we are passing to the trigger and maybe we can console
that we are passing to the trigger and maybe we can console.log the active tab to see if it's working. And I'll open the console. And so I'll click on my settings and you can see the active tab has been set to settings. And then if I click on billing, it's set to billing. It's running twice because we have two triggers on the page, but you can see that it's working. We have passed a value string from the trigger
but you can see that it's working. We have passed a value string from the trigger to the main components, the MyTabs component where the context API is wired up. And now when clicking this button we are able to modify the state which is set up in the parent component and then access with the context consumer. Okay, I think we should set some active styles to the trigger so that instead of logging the value in the console,
to the trigger so that instead of logging the value in the console, we can actually have visual feedback of which tab is active. And the way we can handle this is by comparing the value to the active tab string. And if they're the same, that means that the current tab is active. So let's use the CN function once again here and I think I will remove the bg-slate-200 class text-slate-800 and instead we'll use this in a conditional check.
and I think I will remove the bg-slate-200 class and text-slate-800 and instead we'll use this in a conditional check. So is value equal to activeTab? And if so, we'll go bg-yellow-200, text-yellow-800. And if not, we'll go back to our bg-slate-200 and text-slate-800. So we have the default button styles here and then if the tab is active, it's gonna be yellow, otherwise slate and nice we can toggle our tabs. And so I can get rid of the console.log here.
otherwise slate and nice we can toggle our tabs. And so I can get rid of the console.log here and I think we can move on to the tab panel. But just before I do this, I want to abstract away this context consumer thing here instead of in each component having to get the context check if it's here and then give a valuable error message. If not, I think we can make a nice little custom abstraction here.
If not, I think we can make a nice little custom abstraction here. We're going to create a custom useTabs hook, that's pretty simple but also really useful. So at the top of the file after the context, let's create a function called useTabs. This is a custom React hook that we are creating. And inside of it we will do exactly what Copilot suggests, which is basically what we did.
And inside of it we will do exactly what copilot suggests, which is basically what we did before we get the context from tabs context with the use function. If it's not there, we give that meaningful error message. And if it's there, we just return the context. So with this hook in place, what I can do is go back in the trigger and instead of doing all of this, I can directly try to destructure the activeTab.
and instead of doing all of this, I can directly try to destructure the activeTab and set activeTab using this useTabs hook a nice clean one-liner. And if this is not available, we are going to have a meaningful error message. Alright, so let's move on to the TabPanel. And we are going to do exactly the same. We will not export the function, we will just define it here, function myTabPanel,
We will not export the function, we will just define it here, function, myTabPanel, and we will make this available as a sub component of the myTabs component like so. So this is going to be very similar to the trigger. It's going to receive a value as well. And then the rest of the props. So the value is a string and then we are going to have components, props. And here let's pass huh a div element I guess.
and then we are going to have components, props. And here let's pass huh a div element I guess. And inside we we're going to return a div element that spreads all the props, remember including the children and how to determine if this tabPanel should show or not. We can once again access the context with const. activeTab equals useTabs() here. I don't think we need to set the activeTab, but we definitely need to consume it because if activeTab is not equal to the value
but we definitely need to consume it because if activeTab is not equal to the value of the tabPanel, we are going to short circuit and return null. So no component is rendered, but if it's active after this, we actually return the div. Hopefully that makes sense. So let's try to consume the MyTabPanel component. Remember it's available as myTabsPanel. And really we are doing the same thing.
Remember it's available as myTabsPanel. And really we are doing the same thing that is happening here with the tabs content. So I will have a myTabsPanel and it's important to set the same value that we have for the triggers. So settings and inside of there, uh, the children will be rendered. So I can have anything I want. I can have an h2,
So I can have anything I want. I can have an H2, this is the settings panel followed by a LM Ipsum paragraph. And let's just give basic styles to this H2 text, lg font, semi bold. And for the paragraph we'll give a class name of empty-2 and I will duplicate this, my tabs panel to make the other panel for the billing.
and I will duplicate this, my tabs panel to make the other panel for the billing. And maybe this one doesn't have any H two just to show that you can have anything that you want. And so I'm hoping that at this point the tabs are going to work. When we click on the settings trigger, it'll set the active tab to settings, it'll match here. And so render this tab and then when we click on the other one,
And so render this tab and then when we click on the other one, it'll render the other one. So we are on the billing tab which doesn't have a heading tag, so it seems like it's working. And when I click on my settings, we have the settings panel. Nice. Again, let's add some simple stars. Class name equals mt-2, max-w-xl. Uh, let's give you the border-slate-300 and maybe some internal padding with p-6.
Adding Accessibility Attributes20:12
Uh, let's give you the border slate-300 and maybe some internal padding with p-6. Alright, and all of a sudden this is starting to look like tabs. Nice. Okay, let's wrap it up by adding some functionality to our tabs. We are not going to recreate all the keyboards navigation and accessibility from Radix UI, but let's go and update some html attributes based on whether the tab is active or not.
and update some HTML attributes based on whether the tab is active or not. So remember in the chat and UI version we had things like aria-selected that was set to false or true and also the data-state was being changed. And if I scroll down here, you should see that there is also a tabindex that goes from 0 if it's active to -1 if it's not right now for our tabs, nothing is changing except the class names.
If it's not right now for our tabs, nothing is changing except the class names. But we are going to go and add these attributes that we've just looked at. And this part is actually super easy. So in the trigger here, all we need to do is add an aria-selected and compare the value to the activeTab. So it's going to be true when there's a match or false when there's none. And if we wanted to recreate the data state attributes,
or false when there's none. And if we wanted to recreate the data state attributes, just like in redx ui, we could do it just like so. So you can see here we have three places where we do the comparison here. So maybe we could have a const, isActive and co-pilot knows exactly what I'm trying to think. And so we could go and replace these three checks here. 1, 2, 3 with isActive. And we'll use that check one more time.
1, 2, 3 with ease active. And we'll use that check one more time to set up the tabindex. And again, if it's active, it's zero, otherwise it's minus one. So now we have a slightly more fleshed out trigger button. And so if I inspect the mySettings button, you can see it has aria-selected false data-state inactive and tabindex minus one. But when I click on it, these values will be updated.
and tab index minus one. But when I click on it, these values will be updated to true active and zero. And if you squint a little bit, you can imagine that we could add all the keyboard navigation functionality in the trigger and do also stuff here for accessibility concern. But the key takeaway here is we make this components the trigger and the panel as sub-components of the parent component.
trigger and the panel as sub-components of the parent component. And these should never be used without the parent components. And matter of fact, thanks to our custom hook that we've created up here, it is going to warn us against that. So if I was to render these without a MyTabs parent wrapper, we wouldn't get any errors or warnings in this file, but DUI would crash.
parent wrapper, we wouldn't get any errors or warning in this file, but DUI would crash and we would have these errors saying use tabs must be used within a myTabs component. And as the whole concept about compound components is that they work together and you will not be able to use the sub components without the parent components. Alright, I wanna do one more tiny thing to our component. Check this out. If I refresh the page,
Alright, I wanna do one more tiny thing to our component. Check this out. If I refresh the page, there will be no tab showing and it might be what you want, but here I want a default value, either my settings or billing. Again, just like SHA CNUI has a default value on the main component, we can recreate that functionality. So we are going to say that the settings should be the default value. So we will pass this default value as settings.
should be the default value. So we will pass this default value as settings. So that doesn't exist yet, but we will go and add it in the MyTabs component, which is the root or parent component. I will add a default value, which is a string. And instead of setting the initial active tab to an empty string, we can set it to the default value. And so now if I refresh the page, the active tab is still going to be the settings.
And so now if I refresh the page, the active tab is still going to be the settings. If I did not have a default value, the myTabs component would tell me that I need it. And let's try verify it works by changing the default value to billing. So I am only my settings tab and when I refresh the page, the default value is billing. So everything works alright, and that is compound components for you.
So everything works alright, and that is compound components for you. So there are composable components like the components we've looked at in the previous episode for the table components. But the big difference is there is a wiring of implicit states between the parents and the sub components and you cannot and should not use these sub components without the parents. I hope you enjoy this one. I'll see you in the next one.
I hope you enjoy this one. I'll see you in the next one.
