Skip to content

Instantly share code, notes, and snippets.

@jordanrios94
Last active October 4, 2018 14:12
Show Gist options
  • Save jordanrios94/46af58bdd72e42837eed348ce6edc9c0 to your computer and use it in GitHub Desktop.
Save jordanrios94/46af58bdd72e42837eed348ce6edc9c0 to your computer and use it in GitHub Desktop.
Compound Components

Compound Components

What is it?

  • Give more rendering control to the user
  • The functionality of the component stays intact, but the look and the order of the children can be changed
  • Allow an implicit way of sharing state between components without manually passing it

Can't we just use Render Props?

const App = () => (
  <Select defaultValue="ketchup">
    {({activeValue, onSelect}) => (
      <Fragment>
        <Option onSelect={onSelect} value="ketchup" active={activeValue}>
          Ketchup
        </Option>
        <Option onSelect={onSelect} value="mustard" active={activeValue}>
          Mustard
        </Option>
        <Option onSelect={onSelect} value="mayonnaise" active={activeValue}>
          Mayonnaise
        </Option>
      </Fragment>
    )}
  </Select>
);
  • We can, but sometimes you want to hide all the magic, but still give some control to the user. See how simple it looks below compared as to the render prop version at the top.
const App = () => (
  <Tabs defaultValue="home">
    <Tab value="home"> Active </Tab>
    <Tab value="about"> About </Tab>
    <Tab value="contact"> Contact </Tab>
  </Tabs>
);

But how does Tabs give any information to Tab without explicitly passing props? What about the nested children?

The underlying question can be resolved with the following.

  • React.cloneElement()
  • Context
  • react-call-return

Key principle of what makes a compound component

The Tab component

Dumb af, it doesn't care how they're pass and it just wants to receive props

const Tab = ({active, onSelect, children}) => (
  <div
    onClick={onSelect}
    style={{
      backgroundColor: active ? 'purple' : 'white'
    }}
  >
    {children}
  </div>
);

The Tabs Component

  • should contain all the state
  • Should pass the props to each tab component
class Tabs extends Component {
  state = {
    selectedValue: this.props.defaultValue
  };
  selectTab = value => {
    this.setState({selectedValue: value});
  };
  render() {
    const {children} = this.props; //all tabs
    return magicallyPassPropsToChildren;
  }
}

Passing data to children

Using React.cloneElement

Tabs.js

class Tabs extends Component {
  state = {
    selectedValue: this.props.defaultValue
  };
  selectTab = value => {
    this.setState({selectedValue: value});
  };
  render() {
    const {children: tabs} = this.props; //all tabs
    return React.Children.map(tabs, tab =>
      React.cloneElement(tab, {
        active: tab.props.value === this.state.selectedValue,
        onSelect: () => this.selectTab(tab.props.value)
      })
    );
  }
}

App.js

const MagicTabs = () => [
  <Tab value="home"> Home </Tab>,
  <Tab value="team"> Team </Tab>
];

const App = () => (
  <Tabs defaultValue="home">
    <div>
      <Tab value="home"> Active </Tab>
      <Tab value="about"> About </Tab>
    </div>
    <Tab value="contact"> Contact </Tab>
    <Tab value="other"> Other </Tab>
    <MagicTabs />
  </Tabs>
);

The above implentation assumes every direct child of <Tabs /> in App.js is <Tab />. However that is not the case when developers start to tinker with Tabs and start putting them in <div> or make use of them in separate components such as MagicTabs.

Soooo... context API to the rescue.

Context

Remember to have React 16.3 or above. In the previous versions of React it was not recommended.

The API itself?

//create context
const ThemeContext = React.createContext('light');

This gives us two components:

  • ThemeContext.Provider
  • ThemeContext.Consumer

Consuming context

const Header = () => (
  <div>
    <ThemeContext.Consumer>
      {theme => (
        <div style={{backgroundColor: theme === 'dark' ? 'black' : 'white'}}>
          Hello user
        </div>
      )}
    </ThemeContext.Consumer>
  </div>
);

Providing context

class App extends Component {
  state = {
    theme: 'dark'
  };
  render() {
    return (
      <div>
        <ThemeContext.Provider value={this.state.theme}>
          <h1> Hello world </h1>
          <Header />
        </ThemeContext.Provider>
      </div>
    );
  }
}

But how can I provide default values for context?

Well....

  • If a component uses a Consumer but a Provider is not providing any value, the default value from .createContext(defaultValue) will be used
  • i.e if the context is const ThemeContext = React.createContext('white') the default value will be white.
  • The provided value (and the default value) can be of any type: array, object, string, number.
const TabsContext = React.createContext();
class Tabs extends React.Component {
  state = {selectedValue: this.props.defaultValue};
  selectTab = value => {
    this.setState({selectedValue: value});
  };
  render() {
    const {children} = this.props; //all tabs
    return (
      <TabsContext.Provider
        value={{
          selectTab: this.selectTab,
          selectedValue: this.state.selectedValue
        }}
      >
        {children}
      </TabsContext.Provider>
    );
  }
}

How can the Tab component consume the context?

It is possible to wrap the whole return value of render with the context.

const Tab = ({value, children}) => (
  <TabsContext.Consumer>
    {({selectTab, selectedValue}) => (
      <div
        onClick={() => selectTab(value)}
        style={{
          backgroundColor: value === selectedValue ? 'purple' : 'white'
        }}
      >
        {children}
      </div>
    )}
  </TabsContext.Consumer>
);

However this can be repettive if context is not just for Tabs. In that case write high order component (HOC)

withCotext.js

const withTabContext = Component => props => {
  return (
    <TabsContext.Consumer>
      {({selectTab, selectedValue}) => {
        return (
          <Component
            {...props}
            active={props.value === selectedValue}
            onSelect={() => selectTab(props.value)}
          />
        );
      }}
    </TabsContext.Consumer>
  );
};

Tabs.js

const Tab = () => <div> ... </div>;
const withTabContext = () => {};
class Tabs extends React.Component {
  static Tab = withTabContext(Tab);
}

App.js

const App = () => (
  <Tabs defaultValue="home">
    <Tabs.Tab value="home"> Active </Tabs.Tab>
    <Tabs.Tab value="about"> About </Tabs.Tab>
    <Tabs.Tab value="contact"> Contact </Tabs.Tab>
  </Tabs>
);

Conclusion

Finally, if this is the first time looking at this pattern, then all I can say it is powerful for separating component compostion from basic/dumb/atoms/stateless components and just rely on the compound (the parent component) to manage the props it needs. Therefore becomes easier to debug as the first intention is to look into the compound component.

It allows developers to look at a compound component and easily add to it, e.g. in this case adding a new prop without the need to change in other places.

And last of all achieves DRY and KISS. We are not repeating onSelected prop, and it takes 5 lines of code to render a tabs navigation when Tabs is impoorted.

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