Design systems aim to bring consistency to a brand's design and make the UI development productive. Component libraries with well thought API can make this a breeze. But, sometimes an API choice can accidentally overstep and slow the team down! There's a balance there... somewhere. Let's explore some of the problems and possible creative solutions.
Walking the Line Between Flexibility and Consistency in Component Libraries
From:

React Summit 2022
Transcription
I'm Sid, I work on the design system team at GitHub, and I am one of the maintainers of Primer react, which is the react component library that other teams at GitHub use to build the things that you actually like to use. So before I start, I kind of want to give a warning. So GitHub is part of Microsoft now, so I have to say this. These opinions are my own and not of my employer yet. I'm trying to change them, I'm working on it. I haven't been there long enough, so give me a while. So let's talk about consistency and flexibility when it comes to component libraries. So the thing with component libraries is that there's no one true answer, and it really depends on a bunch of context. So when I started, I started looking at other component libraries. So that's big enough. I looked at Polaris, which is shopify's design system, and I looked at Material UI, which is an open source component library. And this is one component that they have both, like a banner. So with Polaris, how you create this banner is you say I want a banner. The status of this banner is critical, and here's the title. And it renders all of that, the icons there, the cross button is there, all of that is there. And if you want to add an action, you say action object, where you say this is the content, and if this is clicked, this is the function that you should call. So very, very descriptive. There's a lot of config here. If I have to build the same thing with Material UI, that looks something like this. I have an alert. They don't call it a banner. They call it alert. Doesn't matter. And you give it a severity of error. And then you can pass it an icon. So it could be any icon. It doesn't have to be this icon. You could basically give any icon. It's very flexible. And then it also lets you override some of the css. I can actually change the border color here, like I have. And then finally... Ouch. Okay. Finally, if I want some content inside it, there is an alert title. But if I want a button, I can just bring my own button and put it there, and then I'm responsible for this margin that you see here. Okay. So if you compare these two component APIs, they're creating the same component, but there's like a lot of difference between what the api looks like and how much flexibility do you have. And that brings me to the spectrum of flexibility. So when I think of shopify's Polaris, I think Polaris is somewhere here. It's very opinionated. It has an extremely strict api. And that kind of leads to these predictable consistent outcomes. You can use the alert wherever you want. You get something similar. But of course it can get a bit rigid and restricting if you're experimenting with it. On the other hand, Material UI is probably all the way here. It's open source, so there is no assumption of context. It has to have a very flexible api, because the Material team cannot really guess what you'll use it for. Which means it kind of depends on your company, your context. And if you're not careful, if you use Material UI as your design system, it can become messy or fragmented, or you just need a lot of guardrails in place. So when I was looking at Primer react, I was like, okay, where do we lie? Or if you're thinking about it for your component library, the answer of course always is it depends. And it depends on these factors, though. So is it built for a specific product, or is it open source? Is the design decision centralized because you have a certain design team? Or do individual products have their own design team? So is it scattered? How old is the product? How mature are the patterns? If you already have established patterns, then it's easier to be more consistent. Versus if you're still finding the patterns, then it's better to be more flexible. And I looked at this and I was like, we built for a specific product, we do have a centralized design team, we have established patterns, so GitHub's super old. So probably somewhere here. This is how much flexibility we probably should allow. So to showcase this, I looked at this component. This is kind of a common pattern that you see in a bunch of places. What I want you to focus on is just this part, the list of people. And we call this an action list, because it's a list and you can take an action. I'm very clever. So this pattern gets used in a bunch of places. This is a user list that I just showed you, but in a bunch of these places, they're all lists of actions that are distributed, sometimes in a menu, sometimes not. So to build this, I looked at the simplest example. I was like, okay, this seems easy. This is a list of items, you can click them and that's an action. So I said, okay, it's an action list. It takes an array of items, because I want it to be a very restrictive api, so I went with a config-style approach. And if you want to select something, you can click it and I will pass you back the item that you passed so you can figure out what to do. It felt a bit icky, because you're basing this on text. So I changed it a bit. It's an object now. So you have text and then you can do an on select. This is what gets fired. Config api. Then next up, I looked at these dividers. It makes sense. You want to divide your options by context. So if you want to add a divider here, what would the text be? So do you just say, like, this is a divider? Or do we have a secret code where if you say underscore divider, it's like a magic string? Maybe it's a type divider. There's no nice way to do it. So what I decided was instead of relying on magic strings, let's bake this into the component. So because react components are functions, javascript functions at the end of the day, you can always attach more attributes to them. So basically here I'm attaching another key, which is divider, and internally we can change this. It's a magic string internally, but for the users of our components, they'll say action list.divider. Okay, so far so good. Now let's look at this label. So it's the same list. You're probably looping over somewhere, like report.labels, and the text and the onSelect make sense. The text is there. But then you have this circle that we show for colors. So maybe it's label color and the component, the action list component is smart enough that if you pass label color, then it knows it has to render a tiny circle there. That makes sense. But what about this one? Assignee. So the text and onSelect are still fine, you pass the text, but maybe you pass an avatar, and again, it's smart enough to know that if you pass avatar, then it has to render a tiny image with the URL passed. Good work. But of course, there's more. There's milestones and projects, et cetera. So in milestones, there's an icon here. So I guess we just add an icon prop again, and then if it's an icon, it's smart enough, it knows where to render the icon and what size it is. But this is where it starts getting tricky, but what if somebody passes an icon and avatar both? I mean, I'm not saying somebody is trying to break my component, but it makes sense. Like if somebody is trying to make a selection state, this is how you would do it probably. But of course, it kind of messes it up because we don't actually want selection to be this way. There's a different way to do it, but the api kind of misguided you. So change it up and said there's one element allowed in prefix. We're going to call it prefix element, you can pass the component, and then you can pass what props does it take. So any time you see something like this, which is like here's the element, here's the props, it's a component, right? So maybe we should just call it prefix and accept the component here. It kind of opened up the api a little bit, but again, as I said, people are not trying to break the component. People are just trying to build what they have to and then go home. So prefix component. We don't call it prefix. We call it leading visual. That's not important. So this kind of works because you can have avatar, you can have a label color component that you can put there, you can have a milestone icon. So it kind of works. The api is looking a bit tricky because the text is a text. I guess this is a string. Then onSelect is a function, and leading visual is a rendered component. It's JSX. So the api is a bit like you can't guess all this. If you look at the docs, you'll know what to do. Okay, moving on. There is description. We show the name over here, so I add the description field. But in labels, because label descriptions can be really long, you want it on the next line, so maybe that's like a description, label description, but then a description variant, because description can be inline or it can be block. I guess it works. This also is starting to feel like a component, but we'll deal with that later. And then the final boss of this is there's this component, which is like it has groups, because when you want to assign a reviewer, we show you these are the suggestions and this is everyone else. So the way to do this would be like add a group prop, right? Another prop, why not? And then if the user has recently edited, then you say suggestions, otherwise it's everyone else. But how do you decide which one shows up first? The suggestions show up first, or everyone, so you've got to give me an order. So there's another prop called group order. It's an array, but then we have this variation. In some places we want to fill it, so I need another thing called variant, a group variant, I guess. So this becomes a config, this title, this variant. And the good thing about this is that now I have an object, which means I can add an ID, so that at least I can say it's ID, like group one or group two. And then did you notice there are two descriptions here? There's an inline one and a block one, so I guess we're going to do maybe just an array of descriptions, and then which one is inline, which one is block. So maybe there's a description variant array. This feels stupid because there's two of them, so maybe it's an array of objects. And this is how it goes. So the only nice thing about it is it's very easy to type this. If you write typescript, then it's an object. It's very configuration-based. You can type the whole thing. But what ended up happening was folks wanted to put an icon here, put a count here on the right side. In the end, this is what the api started to look like, where there was a render item prop, where it's a render prop, it's an inversion of control. We pass you the item props, and we say, please pass these to whatever you're going to render here, because there's accessibility concerns here. There's a lot of magic that happens for focus trap and keyboard navigation with up and down. So we want all of that to work, and we ask you to pass it back. I'm really afraid of something like render item, because we kind of lose all of the control or all of the features that we're baked in, and we're kind of relying on the user to make sure all of that glue code for responsive design, for accessibility, they have set it up correctly, and they pass it down right. I hate this. And especially this last pattern of render item, the problem with that is not that people would do mistakes, although it does happen, it's not like people are trying to break it. The idea is just that the component became so complicated with all the props that at some point we just had to declare prop bankruptcy and say, here's a function, you do it, I don't care. And that's like ejecting from a component, right? Which kind of defeats the whole point. So all of that is actually a symptom of misunderstanding how flexible did we actually want the component to be. Because if we did not want as many variations, it probably could still work, but I learned that we're probably further down here, even though we have all of the things that I said it depends, but GitHub is big enough that there are so many contexts where the same component can appear, and it changes from component to component. So let's try this again with a slightly more flexible api in mind. All right. So the answer that I went for is let me try composition, let me try to make a composite component, so there's an action list like before, but instead of receiving an array of items, it accepts a child, which is action list.item. And dot item knows how to render things the same way. And like before, you can attach more components onto one component, because it's all functions at the end of the day, so I can say here's an item, and then action list.item is that component, and then people can use this nice composite api. So the nice thing is on select is already on the item itself. This is probably where you'll guess to add it also. When you're selecting an element, you put the on select or the on click on it, so it makes sense. And then you have all of these items. I really like this so far, because it kind of makes sense, it looks tiny, it's not very confusing. Next step, dividers. If you had to guess what the divider would look like, what would you guess? Okay, three seconds. One, two, three. That's a good answer. So we did this before, where we had all of these codes for divider. Now it's just action list.divider. We have the option of baking components and queuing them into action list, and then we say action list to divider, you put it, it knows how to render, it has accessibility handled already. Perfect. All right, next one. Let me open labels. So this is how we did that earlier, where there was a leading visual. I kind of like this. I don't want to change too much. I'll just add an action list.leading visual, and this leading visual knows where to render this, whatever goes inside. So you can say this is the label color, and this is the area reserved for leading visual. If there is a leading visual, then it shifts the text, handles the margin, spacing, size, all of that. So it kind of almost gives you like a slot, if you think about it. You can fill the slot with a label color, or an avatar, like this one. So if you think about these two APIs, the basic structure is still the same. You're only changing the slots that go inside, like the text or the leading visual. So that's pretty good. Like, if you can build multiple UIs with the same api, you kind of have to learn less, and you're going to get your task done faster and go home faster, which is a win for everyone. All right, now let's look at these descriptions. So earlier, we had a key here, label.description. I'm just going to do the same slot behavior. We have an action list.description, and you can pop this description right here. Whatever you write inside goes into this description area. And now if I had to do a variant of block versus inline, where would you put this variant prop? Probably put it here. Because this is the description, and this is where description goes. I'm going to put a variant block here, which will do this. So a lot of these APIs kind of become easier to guess. When you want a variant on the description, where do you put it? You put it on action list.description. Kind of makes sense. All right, let's keep moving. All right, quick. I'm running surprisingly low on time, so I'm going to quickly go through how the slots implementation works. There's an object of slots inside item, and then we loop through children. So I say react-children.forEach. This is like a top-level react api. And then while I'm looping through child, while I'm looping through children, I check what is child.type. And the fun thing is all of this JSX gets converted to react.createElement, and the type is the function that is similar to the component. So if I actually look at child.type, it's going to be one of these, where it's going to be action list.leadingVisual or action list.description. So I can actually just check for if child.type is action list.leadingVisual. I'm just going to kidnap that child, and then put it in a slot. That sounds so wrong. I'm going to fill the slot of leadingVisual with this child, and then I do the same with action. And I can also check the props of the child. So I can say if child.props.variant is block, then slots.blockDescription, otherwise slots.inlineDescription. And if it's none of these, then I know it's in the free form, the text field over here. So I say slots.freeform.pushChild. I see some eyebrows raised. Fair, fair. You have to remember that this api would only work when you have full control over, or you can expect what will come inside children. So in this case, with action list.item, these are the only things you can put. You can either use an action list.leadingVisual, or description, or just text. If you wanted to put anything else, it wouldn't work, because it would just go in the free form slot, and we wouldn't identify. And you can get away with optimizations and nice APIs like this, or let's just call it what it is, you can like child kidnapping hacks, if you control the api. And because this is in a component library setting, we kind of control what the api looks like. And again, people are not really trying to break the components, people are trying to get the job done and go home. So if it's a predictable api, do all the hacks you want. You have my permission, if it matters. Okay, so quickly looking at the milestone, projects, icon, all of that still works, because we just fill the same slots. And then finally, let's look at this big boy. Here we have groups. So again, if you had to guess what would be the syntax for group, what would you guess? Okay, I am so disappointed. Maybe it's not as obvious as I thought. It's going to be action list.group, because then you can put two groups. The group expects title, and you can customize it with a variant, kind of how you do with description. And then inside them, you're just putting action list.items. And everything, this all is similar. It's just leading visual description. But it's very intuitive how you can, if you can make this, you can kind of make a lot of UI on GitHub, which is cool. And then finally, if you wanted both of these descriptions, you just put two action list descriptions, one with variant inline, one with variant block. And both will identify the slot they have to fill, fill those slots, and render in the right places. So no arrays. I mean, it's an array, but we don't see it. All right. Finally, I'm low on time, so I'm going to skip this, because it's kind of boring. All right. Finally, let me say this. You have to experiment to find your spot on the flexibility and consistency spectrum. I thought I knew where we were on the spectrum. I was clearly wrong, so you kind of have to play around with it a little and find your place. Use composition to allow flexibility when you have to, instead of ejecting. So like, render, inversion of control is good when you don't have any control, but in a lot of cases, you kind of have assumptions, or you kind of can predict what people want to do. So composition over ejection. And finally, design systems and component libraries are built for people. So people are not really trying to break your api. I think that's a concern that we have a lot, where it's like, no, people will use it this way and what will happen then? People are not trying to make a fool of us. They're just trying to get the right output, and they have a thing in mind, and they want to move on. So if you can create predictable APIs, even if it involves crimes, it's fine. Thank you so much. That's all I have. If you want to look at the slides, they're on this URL. And if you want more bad ideas, you can follow me on Twitter. Thank you. Thank you so much, Sid. Love chatting with you. Come over here. We can convene on this nice react logo that I love here. How are you doing? How did the talk go for you? Good. Good, good. So folks, just in case you have some questions, and you haven't yet put them in the Slido, make sure you check out the Slido, and you ask those questions. Let's jump straight to it. We've got one of them who asks, we've got Nicola who asks the question, why not approach it like build a more flexible api at a lower level of abstraction, and then build an opinionated api as a layer on top? That's a good point. We kind of do that, where a lot of the leading visual and description are built on lower level APIs. So there's like an avatar, and there's like a text field. So if you wanted, you could take the lower level blocks and build it yourself. And I think that's also a way of like ejecting, because I think that gets thrown around a lot, where if you don't like the opinionated api, just build it yourself with the lower building blocks. But then you kind of lose out on all the accessibility, responsive. Like there's a lot of glue code that's hiding under the component that you kind of just bail on. So you can if you want, but it's always nice when you don't have to drop to a lower level. Nice. And then the last question that's like jumped to the top is one that I was thinking about as well. And is it possible to write a TS type to only allow action list.xxx as a child of action list? Yes, it is. We have types for this. It's open source. If you go to GitHub slash primer slash react, you can find slots. I'm very proud of like, it took me like a whole week to get the types right. But I got it finally. And yeah, you can you can type it. And if you give something else, then it shows the squiggly lines. Nice, nice. This is a question I was thinking about. I love how the audience are reading my mind. What was the tool? Did you create these slides? Because I need it in my life. Like most of the code I write, it's also like crimes and hacks. Okay, this is this is gonna be annoying. Sorry to disappoint you. But there's no tool. It's just this preview on the left, which is just a list of components that are next next between. And on the right, it's just code. And I've already pre written the code. I just next next between. But because it's a text area, I can pretend that I'm typing and it's doing something. It's not doing anything. It's just slides. I'm just going next, next, next. Nice, faking it till you make it. I know, right? Straight up crimes. And also, like you said, developers sometimes we don't like to follow the rules all the time. So how do you present consumers of the component library to only use valid children instead of just random divs and taking over? Yeah, I mean, the types help. But there's always I mean, you could do a TS ignore and move on with your life. We can't stop you. A lot of times this comes down to like, not just the code, but it comes down to just collaboration. So we're constantly on the lookout for like, I'm always searching who's using actionless. And if they're using in ways that I hadn't predicted, it probably means that they have a pattern that I didn't think of or a pattern that doesn't fit in the api. So unfortunately, the answer is, you got to talk to them and you're going to figure out what they want. How dare the answer be? Oh, my gosh. Oh, my gosh. And how would you implement this filtering on the action this component itself? Oh, that's a that's good. Yeah, so that that was a slide that I skipped. We basically because you're responsible for looping through items, we give you a text text input, which gets rendered at the right spot. It has the right margin, it has a loader and all that. But the actual logic of what happens when somebody types is passed on to the user. So you give us filtered users, and we'll render that and if the keystroke then you change filtered users. And what did we need slots for? Oh, so slots are perfect for places where you have an opinionated component, but you also have just the tiniest amount of flexibility like in this one. It looks always looks the same, but the leading visual could be anything could be a label, could be an icon, could be an avatar, could be something else. But the size and spacing are already controlled. So slots are great for tiny amounts of flexibility that you want to sprinkle without having to redo the entire component. And the next question about refs integrations like material UI, like how would you work with that? Forward ref all the way. So like leading visual forwards the ref, item forwards the ref, description forwards the ref. There's no nice way to do it. You just got to forward the ref all the way down. And this last question because I could see our time has gone red. How do you make sure that nothing breaks from css perspective when you're building api for multipurpose? That's a good question. So I want to say at least in this component, the slots have helped a lot because the slots, they look simple, but they're kind of like very restrictive. There's like a max width and a max height and a margin. And there's a tiny difference in pixels between a label color and an avatar. And for that, there's a min width, max width going on. So there's a lot of like, we tried to build all the use cases that people might use it for and then bake in a lot of guardrails in the css. So that it doesn't go out. Awesome. Thank you so much. See you around. Everyone give him a round of applause. Thank you.