An Opinionated Guide to Component APIs

Jun 20, 2022
John Gozde

John Gozde is the tech lead for the Imply Polaris UI.

When you think of APIs, what comes to mind? REST? RPC protocols and wire formats? Syscalls?

If you’re a frontend engineer, there are domain-specific APIs you interact with every day, but maybe you don’t think of them as such. I’m talking about Components.

Every major UI framework has the concept of a component. They help us encapsulate reusable functionality and set boundaries around what is displayed and how it behaves. It’s useful to think of each component as an API unto itself, albeit one that is a subset of a larger framework-specific component API.

The framework’s API is essentially fixed and beyond our control, but what we can control is exposed to other users/developers who consume the components we build. Things like naming conventions, prop types, and broader patterns for groups of related components are entirely user-level concerns that have downstream impacts.

What follows is a set of guidelines for building React components at the level of a component library or a design system. They are non-exhaustive and represent my personal tastes, but they have been largely extracted from Imply’s internal Canopus design system.

Feel free to use this however you like – fork it, copy it verbatim, or negate every single rule. The details of the guidelines are less important than simply having guidelines at all.

Caveat: It’s more important to be internally consistent than to strictly adhere to this or any other style guide. That is, if the project you’re contributing to has an established set of guidelines that contradict these, it’s better to follow what is already in place, at least for the short term. It’s fine to change the conventions a project uses over time, but it should be done deliberately and as a team.

Use ‘onSubjectVerb’ naming convention for event props

Also known as “callback props”, these are functions that are invoked based on some asynchronous trigger event, such as a user interaction, a network response, or a change in state. Notably, this excludes functions that are invoked during the render phase.

DO assign names to event props like on[Subject]Verb, where Subject is optional and Verb is usually present tense.Examples: onChange, onCancel, onTableScroll, onAvatarClick, onOpenChange, onEntryModeToggle
🤔MAYBE break the above rule for grammatical reasons.Example: onFilesSelected – uses past-tense verb “selected” because onFilesSelect is slightly more awkward to read and say.
DON’T reverse subject/verb or use prefixes like handle, set or other verbs, even if consumers would typically pass a state setter as a value.Bad examples: setEntryMode, handleAvatarClick, changeOpen, onScrollTable

The reasoning behind this guideline is based on 1) consistency with DOM events and 2) avoiding leaky abstractions.

The setSomeThing naming convention is a particularly common mistake I see being made, usually during refactoring. E.g., some state foo that was being kept in a child component was lifted up to a parent component, so the engineer added props to the child called foo and setFoo and moved the useState into the parent.

// ⛔️ Incorrect

function Parent(props: ParentProps) {
  const [foo, setFoo] = useState(”);
  return <Child foo={foo} setFoo={setFoo} />
}

interface ChildProps {
  foo: string;
  setFoo: React.Dispatch<React.SetStateAction<string>>;
}

function Child(props: ChildProps) {
  return (    <input      value={props.foo}      onChange={e => props.setFoo(e.target.value)}    />  )
}

But now we have an abstraction leak. The child should no longer be aware of where foo is stored or how it is updated. Maybe it lives in component state, maybe it lives in a Redux store, or maybe it’s something entirely different – it shouldn’t matter to the child component. All it needs to know is the current value of foo and what function to invoke when it thinks foo should change: onFooChange.

// ✅ Correct

function Parent(props: ParentProps) {
  const [foo, setFoo] = useState(”);
  return <Child foo={foo} onFooChange={setFoo} />
}

interface ChildProps {
  foo: string;
  onFooChange: (foo: string) => void;
}

function Child(props: ChildProps) {
  return (    <input      value={props.foo}      onChange={e => props.onFooChange(e.target.value)}    />  )
}

Include original DOM event as the last parameter

DO consider passing the originating DOM event as the last parameter when adding logic on top of a DOM event.Example: onVisibleToggle(visible: boolean, e: React.MouseEvent<HTMLElement>)

This ensures that users have the option to use preventDefault() or stopPropagation() if necessary. If a synthetic event might be triggered by multiple kinds of DOM events, you can type the event object using a type union or React.SyntheticEvent<HTMLElement>.

Avoid “is” prefix for boolean props

Boolean flags or toggles are props that accept true or false and where ={true} can be omitted when using JSX. They might be used for setting different behavior modes or for controlling some kind of binary state.

As far as naming is concerned, there are exactly two options, both of which are equally valid: “prefix with is” and “don’t prefix with is”. But since this is an opinionated guide, I can only choose one:

DO use adjectives for naming boolean props that describe a component’s state.Examples: open, visible, readOnly, compact, disabled, selected, checked, required
DON’T prefix boolean props with is or has.Bad examples: isOpen, isReadOnly, isLoading, hasMultiple, wasVisible
🤔MAYBE use present participles of adverbs when describing transient state.Examples: loading, savingBut these might also be better encapsulated in a multi-value toggle prop, e.g. state=”loading” or state=”saving”.

Please, put down your pitchforks. While that is the convention I prefer, my advice at the top, “internal consistency above all else,” still applies. It’s better for all toggles to use the same convention within a component or within a library than it is to always use this particular convention.

Prefer ‘false’ as a default value

This guideline is related to the principle of least astonishment. A boolean prop with the default value of true is redundant when specified as a flag, but the reader would only know this by inspecting the source of the component or reading the documentation on the props interface, if present.

DO prefer a default value of false for optional boolean props

Therefore, it’s preferable to invert the logic such that the default value is false and specifying the flag toggles it to true.

// ⛔️ Incorrect

interface SomeComponentProps {
  enabled?: boolean; // default=true
}

<SomeComponent />; // unclear -> do I need to set ‘enabled’?
<SomeComponent enabled />; // redundant
<SomeComponent enabled={false} />; // verbose
// ✅ Correct

interface SomeComponentProps {
  disabled?: boolean; // default=false
}

<SomeComponent />;
<SomeComponent disabled />;

Prefer composition over toggle props, use consistent naming otherwise

🤔MAYBE use a verbSubject pattern for naming boolean props that describe a component’s behavior.Examples: allowDragging, hideSourceColumnField
DON’T add excessive boolean props for toggling between different behavior modes.
DO prefer component composition over excessive use of toggle props.

This pattern is especially common. A new requirement is introduced that requires a slight change in behavior for a specific scenario, so the engineer adds a boolean prop to toggle between the old behavior and the new behavior. And then another requirement is introduced that requires another toggle… and then another…

After N toggle props are added, our component is handling 2N additional behavior modes, which makes it harder to predict what exactly will get rendered and how it will behave for the different combinations that exist.

Past a certain (very subjective) complexity threshold, it’s usually better to refactor this component and use composition to toggle different behavior modes rather than props. But won’t this shift the complexity to where the component is being used? Yes it will – but that complexity exists because the different usage sites have different requirements from each other, so our boolean prop was misleading us.

Prefer string-based unions for multi-value toggles

This one is fairly straightforward. Props that toggle between one of many states or behaviors should be typed as string unions, avoiding enums, objects, or other values that necessitate imports. The reason behind this rule is more for convenience and DX than anything else.

DO prefer string-based type unions for multi-value toggles.
DON’T use enums, const objects, or other values that require imports.
// ✅ Better
interface SomeComponentProps {
  state?: ‘loading’ | ‘saving’ | ‘idle’;
}
<SomeComponent state=”loading” />

// ⛔️ Worse
enum SomeComponentState {
  Loading,
  Saving,
  Idle
}

interface SomeComponentProps {
  state?: SomeComponentState;
}
<SomeComponent state={SomeComponentState.Loading} />

The approach using string unions is terser, more readable, and offers the same level of type safety as the approach using enums. It also avoids extra import statements and supports refactor/rename operations with recent versions of the TS language server.

Prefer value types for props

Picking the appropriate name for a prop is half the battle; the other half is choosing its type. React runs on JavaScript and both have quirks that influence which types are more or less appropriate for props.

For instance, many optimizations in React depend on referential equality comparisons for state and props in order to avoid doing unnecessary work. This works well for value types, like strings, numbers, and booleans, but it completely falls apart for arrays, objects, dates, and all class instances.

[] === [] // => false
[1] === [1] // => false
{} === {} // => false
{a:1} === {a:1} // => false
new Date(2020,0,1) === new Date(2020,0,1) // => false

This one language property is the achilles heel of optimization in React, which was seemingly designed for a more civilized language than what is available to us now. Records and tuples may alleviate some of this pain in a future ES runtime, but for now we need to address these issues at the component API level.

DO prefer value types for props.
🤔MAYBE inline object properties into individual props.

There are a plethora of valid reasons why we might choose an object or an array for a prop’s type. Maybe we’re rendering a list of items; maybe there’s an external dependency that accepts configuration as an object; maybe there are some props that should always be set together.

In some cases we can avoid a reference type but in others we can’t. It really depends on the situation.

Case 1 – arrays

Consider a component that renders a list of items, and the items themselves are fairly expensive to render. Using an array for the items prop is really the only choice we have (as of this writing). There are 3rd party libraries for constructing immutable lists, but due to limitations in JS as language (lack of operator overloads), it’s impossible to make referential equality, a === b, to behave like value equality.

If either the component or the items prop are memoized in some way (e.g., PureComponent, memo, useMemo, etc.), then this memoization is very easy to break for consumer components. All they need to do is specify a value like items={[1, 2, 3]} and the memoization is broken. We can even break it ourselves by setting a default value like items = [] because empty arrays are referentially unequal.

In this scenario, we have two options, neither of which are ideal. Option 1 is to clearly document that items is memoized for performance reasons so consumers should take care to do the same. Option 2 is to write a custom equality function for PureComponent (shouldComponentUpdate) or memo (areEqual) that uses shallow comparison for items. But option 2 is not available for useMemo, unfortunately.

Case 2 – non-finite objects

Another scenario similar to the above would be for object props that have a large number of properties or where the properties may have arbitrary keys. Like with arrays, our only options are to document or somehow use shallow equality internally.

Case 3 – finite objects

But consider a different scenario where we are building a component that has a default size, but that size can be changed by the consumer. Say we expose this via a size prop, typed as an object taking width and height properties:

interface MyProps {
  foo: string;
  bar?: number;
  size?: { width: number; height: number }
}

This type is deliberately forcing consumers to choose between not specifying any size or specifying both dimensions simultaneously. But again, this component is expensive to render, so we are memoizing the whole thing or maybe just the size prop. This has a similar issue as the array case above because if a consumer provides size={{ width: 20, height: 20 }}, that memoization will be broken.

Unlike the array case, however, there is a 3rd option available, which is to inline the size properties directly onto the props interface:

interface MyProps {
  foo: string;
  bar?: number;
  width?: number;
  height?: number;
}

Semantically speaking, this is not identical to the original props because now it’s possible to specify width without specifying height and vice versa. If we wanted to be really pedantic, we could use separate props interfaces and function overloads to enforce that both are set or not set:

interface MyProps {
  foo: string;
  bar?: number;
}
interface MyPropsWithSize extends MyProps {
  width: number;
  height: number;
}

function MyComponent(props: MyProps): JSX.Element ;
function MyComponent(props: MyPropsWithSize): JSX.Element;
function MyComponent(props: MyProps | MyPropsWithSize) {
  // return …
}

But this is a little bit overkill and would escalate in complexity very quickly if we wanted to do the same for other prop combinations. A more practical solution would be to simply tolerate omission of either width or height and pick an appropriate default.

interface MyProps {
  foo: string;
  bar?: number;
  width?: number;
  height?: number;
}

function MyComponent(props) {
  const { width = 20, height = 20 } = props;
  // OR
  const { width = props.height ?? 20, height = props.width ?? 20 } = props;
}

Use ‘children’ prop for composition

The key to building composable components in React is the children prop. It receives special treatment in JSX – anything between a component’s opening and closing tags is passed to it as the children prop. The component can then decide where in its tree these children should be rendered.

DO accept children for composition and content whenever possible.

When building component libraries, we might be inclined to avoid supporting composition in some components, often for legitimate reasons. Take, for example, a Button component. Suppose we always want a Button to have associated text for accessibility reasons (Buttons that are rendered using only an icon would move their text property to a tooltip and an aria-label). But since the text might be rendered on the screen or it might be added to an HTML attribute, we only want to allow strings. We could build our Button props like this:

interface ButtonProps {
  children: string;
  iconOnly?: boolean;
  icon?: string;
}

This would work exactly as we want – we can toggle between icon-only and icon-with-text rendering while maintaining good accessibility. But then what if someone wants to decorate their button text?

<Button icon=”danger”>
  <strong>Cancel!</strong>
</Button>

This would no longer compile because the children type is incorrect (JSX.Element is not assignable to string). The DOM would happily allow a <strong> inside a <button>, but we have prevented it because if we set iconOnly, the aria label would become [Object object].

So to avoid confusion, we might use an interface like this:

interface ButtonProps {
  children?: never; // disallow children
  iconOnly?: boolean;
  icon?: string;
  text: string;
}

Now our Button doesn’t support composition at all, but users would be less likely to pass arbitrary JSX to a prop called text. This might be an acceptable compromise, but the consequence is that our Button API is no longer composable.

Maybe there’s another compromise we can make?

interface ButtonProps {
  children?: JSX.Element;
  iconOnly?: boolean;
  icon?: string;
  text: string;
}

function Button(props: ButtonProps) {
  const content = props.children ?? props.text;
  return (
    <button {…}>{content}</button>
  )
}

In this version, we allow children to be set if needed, but still require a plain text prop for accessibility. When rendering the content, we look at children first, falling back to text. This preserves composability while enforcing accessibility, at the cost of a bit of extra complexity within our component and maybe some redundancy for consumers that specify both children and text.

Prefer component clusters over “slot” props

Certain kinds of reusable UI elements are difficult to represent with a single component, especially if some degree of freedom is necessary for users. Take, for example, a Dialog component. A naive implementation might be straightforward, but as more UX patterns emerge, we might ask for more customization. Can we disable the header? Can we put arbitrary content in the header? Can we put buttons on both sides of the footer? Etc.

Though we could support extra customization by piling on props, a more natural API might be to create a “clustered” API for our component, where a component cluster is a group of components that are designed to work together and are namespaced under a single root component.

DO prefer value types for props.
⚠️AVOID using element props to define component customization “slots”.

Here is a concrete usage example of a Dialog component that offers customization via clustering:

// ✅ Better
<Dialog open={open} onOpenChange={onOpenChange}>
  <Dialog.Header omitCloseButton>
    My fancy dialog <PopoverButton icon=”INFO”>Details go here…</PopoverButton>
  </Dialog.Header>
  <Dialog.Body>
    Content goes here
  </Dialog.Body>
  <Dialog.Footer css={{ display: ‘flex’ }}>
    <Dialog.Close>Close</Dialog.Close>
    <Dialog.Action css={{ marginLeft: ‘auto’ }} onClick={…}>Cancel</Dialog.Action>
    <Dialog.Action onClick={…}>Confirm</Dialog.Action>
  </Dialog.Footer>
</Dialog>

Every component that might be part of a Dialog is namespaced under the name Dialog, which itself might be a component (we could also alias it as Dialog.Root). These components are designed to work together seamlessly, both visually and functionally, and we indicate that to consumers by grouping them under the same namespace.

Many of the sub-components in this cluster might be considered “slots” – well-defined placeholders where we accept arbitrary content. In this case, Header, Body, and Footer are slots. Some, like Body, might be required while the rest are optional.

The actual implementation of these components might vary depending on the functional complexity. Sometimes it’s a simple matter of stitching things together with CSS, but other times it might require an implicit React Context to tie things together. That’s beyond the scope of this style guide – the important thing is to avoid exposing those kinds of implementation details to consumers.

Note that the above could be designed using props instead of component clusters, which might look something like this:

// ⛔️ Worse
<Dialog
  open={open}
  onOpenChange={onOpenChange}
  omitCloseButton
  header={
    <>
      My fancy dialog <PopoverButton icon=”INFO”>Details go here…</PopoverButton>
    </>
  }
  footer={
    <>
      <Dialog.Close>Close</Dialog.Close>
      <Dialog.Action css={{ marginLeft: ‘auto’ }} onClick={…}>Cancel</Dialog.Action>
      <Dialog.Action onClick={…}>Confirm</Dialog.Action>
    </>
  }
  footerCss={{ display: ‘flex’ }}
>
  Content goes here
</Dialog>

There is nothing technically wrong with this approach, but for a low-level component, the API surface area is too large for the amount of customization that might be desired. An API like this might be suitable as a wrapper for Dialog after a few primary archetypes have emerged, but those would commonly live at the application level.

Avoid “render” props

This is an older component API pattern that was popular in the era of class components. Rather than using composition, components would offer customization in the form of props, which would often directly override an internal render method of the same name. E.g., renderHeader, renderFooter, renderGridBody, etc.

Render props should generally not be used for new component APIs, favoring clusters and slots (described above).

⚠️AVOID render props if at all possible, favoring composition via slots.
DO use props named render* for providing alternative rendering logic when necessary.
DON’T use children as a render function.

The “Don’t use children” rule is especially important with this pattern because a) it was fairly common at one point and b) it defies expectations of most UI component APIs. children should be broadly supported (as argued above) and should only accept renderable elements. APIs that expect children to be specified as functions or objects or anything not directly renderable should be avoided.

Other blogs you might find interesting

No records found...
Jul 23, 2024

Streamlining Time Series Analysis with Imply Polaris

We are excited to share the latest enhancements in Imply Polaris, introducing time series analysis to revolutionize your analytics capabilities across vast amounts of data in real time.

Learn More
Jul 03, 2024

Using Upserts in Imply Polaris

Transform your data management with upserts in Imply Polaris! Ensure data consistency and supercharge efficiency by seamlessly combining insert and update operations into one powerful action. Discover how Polaris’s...

Learn More
Jul 01, 2024

Make Imply Polaris the New Home for your Rockset Data

Rockset is deprecating its services—so where should you go? Try Imply Polaris, the database built for speed, scale, and streaming data.

Learn More

Let us help with your analytics apps

Request a Demo