Skip to content

Instantly share code, notes, and snippets.

@sohara
Last active May 30, 2019 14:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sohara/6e7b524617c58e3d65a451c2f042eef9 to your computer and use it in GitHub Desktop.
Save sohara/6e7b524617c58e3d65a451c2f042eef9 to your computer and use it in GitHub Desktop.
Mitigating proliferation of props when using SoulCylce UI Kit

Mitigating proliferation of props in the SoulCylce UI Kit Header (aka "I want less [sic] props")

The current implemention of the UI Kit header relies entirely on various props passed to the <Header /> component to determine all aspects of what will be rendered within it and when. This allows for a more or less deterministic rendering of the component between Podium SoulCycle Site (PSS) and the older Soul Cycle Site (SCS), assuming both apps pass the same props to the <Header />.

As a component like the header is built out it's child components grow in number and complexity, resulting in a growing number of dependencies. Since the header in its current implementation is effectively a black box, the only way to provide those dependencies to the child components is to pass them down the component hierarchy as props, starting with the <Header /> invocation itself.

Here is the current invocation of the header in PSS in the _app.js file:

import { Header } from 'soulcycle-ui-kit';

// .... //

export default class MyApp extends App {

  render() {
    return (
      <Header
        id="header"
        navItems={regionalConfig.navItems}
        regions={regions}
        defaultRegionId={selectedRegion.id}
        userBadgeProps={userBadgeProps}
        onLogoClick={handleLogoClick}
        onNavItemClick={handleNavItemClick}
        onRegionChange={this.handleRegionChange}
        onRegionToggle={handleRegionToggle}
        onStudioSelect={handleStudioSelect}
        onSubNavItemClick={handleNavItemClick}
        LinkWrapper={sharedConfig.LinkWrapper}
        currentRegion={this.state.currentRegion}
        studios={this.state.currentRegion.studios}
        currentPath={currentPath}
        seriesCount={sharedConfig.seriesCount}
        className={this.state.variation}
        themeMode={this.state.variation}
      />
    )
  }
}

Note that the <Header /> is currently designed to be used as a self-closing tag component as opposed to one with a closing tag and content to be rendered in between.

Note also that currently userBadgeProps is a bucket that contains user and urlList properties that are not really related to one another (this is a known issue that will be addressed).

The diagram below indicates how props are passed down the component hierarchy. Notable is the fact that the <Header/ > and it's immediate child <HeaderDefault /> do not use any of the 16 or so props that it requires in order to pass them down, they are only passed to header as a conduit for its sub-components.

Untitled Diagram

The problem

The situation described above is completely functional, but there are a few pain points or potential areas for improvement:

  • Maintainability: having props set in stone and passed down the entire component hierarchy makes things harder to change. Any change to a child component's dependencies requires updates to the intermediary components
  • Opacity: The long list of props offers no insight into what purpose the various props serve: there is no context beyond that offered by their respective naming to inform the consumer of the <Header /> as to the intent of a given prop.
  • Lack of composition: instead of the app being able to consume a "toolkit" for creating a header and invoking it according to its current requirements, any changes to the requirements for the app's header need to be implemented in ui kit, and possibly the app as well.
  • Control: The UI Kit ends up holding a fair amount of business logic that one typically doesn't expect to find in such a library.

Context API: A Possible Solution

React's Context Api permits an invoking context to set up or acquire state that can then be consumed by a deeply nested component without passing props directly through the component hierarchy. It is most often employed to enable the use of application-wide values (i.e. global state) that do not need to be attached directly to the entire component tree. A good example would be a user object which is used by several components. Insteady of passing them down the complete tree from app.js to e.g. <UserBadge />, the UserBadge could be wrapped in a Context consumer component (or employ a useContext hook) which provides the prop.

In the case of our header the Context API could conceivably address the problems of maintainability and opacity mentioned above, but it would not address the lack of composition or the fact that the header cannot be easily customized from the invoking context.

Consuming Context from a UI Kit

Even if we determined that using the context API alone was the best way to alleviate the issues we are having with so many props passed to the header, it's not immediately clear that it would be possible given our current setup. The context API requires a setup (calling React.createContext) in an uppper-level of a component tree that yields a provider and a consumer. The provider then receives the desired state at the top-level context (e.g. _app.js) and the consumer receives that state in order to pass it down to some nested child component. In an application where both the provider and the consumer exist in the same codebase, this is trivial to implement: both the providing and consuming components can import the module that exports an instance of the API.

In our case, however, the <Header /> and its sub-components do not live in the application codebase, so they cannot import one of the applications's context modules. Some kind of setup could pass them to UI Kit components, but it's unlikely that doing so would offer much improvement over the current situation (passing a context would effectively have the same costs and benefits as passing a prop).

Between this shortcoming and the other problems not addressed by the Context API it's worth looking at other options.

Option 1: Move header and related components to the app

The simplest solution would be to move the header out of the UI kit to the application itself. This wouldn't address any of the issues composeability and opacity directly, but it would make the header slightly more maintainable (no need to work in two repos when making changes). It would also allow the use of the Context API in a straightforward way.

In a normal application this would make sense, since a header is not typically something that gets re-used and therefore not a great candidate for including in a UI Kit. But in our case, because we need to consume the header from both PSS and SCS, any implementation not shared in some library would need to be duplicated in two cases in its entirety. Therefore this is not a great option.

Option 2: Import <Header /> sub-components from UI Kit and invoke from _app.js

A relatively simple way to reduce the number of props that need to traverse the header component hierarchy would be to stop employing <Header /> as a self-closing tag and make it customizable by allowing the importing of some of the individual sub-components and invoking them between the opening and closing tags. This simply means passing them as the children prop to the header, but in this case it would be the responsibilty of the invoking context to ensure they have the correct props, not the <Header /> component.

Such a scenario could look something like:

import { BookBike, Header, Logo, Nav, RegionSelector, SubNav, UserBadge } from 'soulcycle-ui-kit';

export default class MyApp extends App {

  render() {
    return (
      <Header
        id="header"
        className={this.state.variation}
        themeMode={this.state.variation}
      >
        <Logo
          id="soulcycle-logo"
          ariaLabel="SoulCycle Home Page"
          LinkWrapper={sharedConfig.LinkWrapper}
          onClick={handleLogoClick}
        />
        <Nav
          initialUrl={currentPath}
          items={regionalConfig.navItems}
          onClick={handleNavItemClick}
          LinkWrapper={LinkWrapper}
        />
        <UserBadge {...userBadgeProps} />
        <RegionSelector
          defaultRegionId={selectedRegion.id}
          regions={regions}
          onRegionChange={this.handleRegionChange}
          onToggleClick={handleRegionToggle}
        />
        <BookBike
          region={this.state.currentRegion}
          studios={this.state.currentRegion.studios}
          onChangeHandler={handleStudioSelect}
        />
        <SubNav
          initialUrl={currentPath}
          items={activeSubNavRef.current}
          onClick={handleNavItemClick}
          showSeries={
            userBadgeProps && userBadgeProps.user && showSeriesCount
          }
          seriesCount={sharedConfig.seriesCount}
        />
      </Header>
    );
  }
}

This somewhat simplified example is quite a bit longer than the original <Header /> invocation above, thus leading to a bit more duplication between PSS and SCS. It does however address some of the problems mentioned above to significant degrees:

  • No longer do all props have to pass through the gate of the <Header /> component (it now receives only one, compared to 15 earlier).
  • The header's sub-components could potential be re-order or composed in different ways without having to change the <Header /> component itself.
  • Most criticially, the developer gains greater immediate insight into where each prop is used and it's purpose: the header is less opaque and there is less indirection.

Option 3: Header Exposes Public API Via Render Props

A third potential option would be to have the <Header /> component expose its sub-components to the invocation context (consuming component) via render props. Advantages may included packaging props together for the consumer, re-use of certain props among sub-components and binding of handlers so they don't have to be assigned more than once.

An examples of render props already used in PSS is the Downshift library.

import { Header } from 'soulcycle-ui-kit';

export default class MyApp extends App {

  render() {
    return (
      <Header
        id="header"
        className={this.state.variation}
        themeMode={this.state.variation}
        LinkWrapper={sharedConfig.LinkWrapper}
        initialUrl={currentPath}
        onNavItemClick={handleNavItemClick}
      >
        {HeaderComponents => {
          return (
            <HeaderComponents.Logo
              id="soulcycle-logo"
              ariaLabel="SoulCycle Home Page"
              onClick={handleLogoClick}
            />
            <HeaderComponents.Nav
              items={regionalConfig.navItems}
            />
            <HeaderComponents.UserBadge {...userBadgeProps} />
            <HeaderComponents.RegionSelector
              defaultRegionId={selectedRegion.id}
              regions={regions}
              onRegionChange={this.handleRegionChange}
              onToggleClick={handleRegionToggle}
            />
            <HeaderComponents.BookBike
              region={this.state.currentRegion}
              studios={this.state.currentRegion.studios}
              onChangeHandler={handleStudioSelect}
            />
            <HeaderComponents.SubNav
              items={activeSubNavRef.current}
              showSeries={
                userBadgeProps && userBadgeProps.user && showSeriesCount
              }
              seriesCount={sharedConfig.seriesCount}
            />
          )
        }}
      </Header>
    );
  }
}

In this example, LinkWrapper, initialUrl, and onNavItemList are props that are handled by <Header /> because they are used by more than one sub-component. <Header />'s implementation will take care of wiring them appropriately.

Mobile vs non-mobile Views

This sort of composition pattern could lend itself to other improvements such as allowng for different customization of both the mobile and non-mobile verions. For example:

import { Header } from 'soulcycle-ui-kit';

export default class MyApp extends App {

  render() {
    return (
      <Header
        id="header"
        className={this.state.variation}
        themeMode={this.state.variation}
        LinkWrapper={sharedConfig.LinkWrapper}
        initialUrl={currentPath}
        onNavItemClick={handleNavItemClick}
      >
        {HeaderComponents => {
          return (
            <HeaderComponents.Desktop>
              <HeaderComponents.Logo
                id="soulcycle-logo"
                ariaLabel="SoulCycle Home Page"
                onClick={handleLogoClick}
              />
              <HeaderComponents.Nav
                items={regionalConfig.navItems}
              />
              <HeaderComponents.UserBadge {...userBadgeProps} />
              <HeaderComponents.RegionSelector
                defaultRegionId={selectedRegion.id}
                regions={regions}
                onRegionChange={this.handleRegionChange}
                onToggleClick={handleRegionToggle}
              />
              <HeaderComponents.BookBike
                region={this.state.currentRegion}
                studios={this.state.currentRegion.studios}
                onChangeHandler={handleStudioSelect}
              />
              <HeaderComponents.SubNav
                items={activeSubNavRef.current}
                showSeries={
                  userBadgeProps && userBadgeProps.user && showSeriesCount
                }
                seriesCount={sharedConfig.seriesCount}
              />
            </HeaderComponents.Desktop>
            <HeaderComponents.Mobile>
              {/* mobile version here */}
            </HeaderComponents.Mobile>
          )
        }}
      </Header>
    );
  }
}

An additional option that could allow for more composability would be the use of "scoped slots". Since React allows just about anything to be a prop, one can assign nested jsx expressions to a prop.

Conclusion

I think that this exploration has revealed that there are numerous strategies that could be employed to get around the problem of "prop drilling" and the excessive coupling that goes along with it. Context API is certainly an option but not the most obvious one for this problem, at least in its current form.

It should be noted that the code examples here are relatively high-level and don't go into detail showing how to all of our app's current constraints, such as requiring different versions for mobile vs default (desktop/tablet) header and scoped styling support. That said, I'm confident those problems can be solved without sacrificing the benefits offered by the various appoaches considered.

The PSS app also relies heavily on the StyledComponents library, which would also need to be accounted for. Currently most styling is defined in the file where a given component is invoked rather that in the file that exports the component. Possibly this could be changed to allow for easier composeability, or possibly it was a constraint determined by the requirement to support multiple breakpoints.

Overall, I think that inverting some of the control of the header from the UI Kit to the PSS and SCS apps is the way to go, despite a marginal increase in duplication between the two applications.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment