Smart Components in React

Part 2: Render Prop and the Context API

Agenda

  1. Recap Smart Components
  2. Render Props
  3. React Context API

Recap: What are Smart Components Used For?

  • Shared/cross-cutting concerns
  • Fetching data sources, e.g. fetch()
  • Handling side effects (e.g. track an analytics event when the component mounts, do some DOM manipulation outside of React)

Benefits of Smart Components

  • Promotes reusability of presentational/dumb components
  • Separates app logic from UI logic
  • Can alleviate prop drilling

When NOT TO use a Smart Component

Rendering <JSX /> with styles directly​

Ways to implement

  • Higher Order Components ✅
  • Render Props 

What are Render Props

"...refers to a simple technique for sharing code between React components using a prop whose value is a function."

Example 1: Using a prop called render

// Smart Component
class DataProvider extends React.Component {
  state = { message: 'world' };
  render() {
    return this.props.render(this.state);
  }
}

// Dumb Component
function HelloWorld() {
  return (
    <DataProvider render={(data) => (
      <h1>Hello {data.message}</h1>
    )}/>
  );
}

Example 1: Using a prop called render Transpiled

// Not transpiled
class DataProvider extends React.Component {
  state = { message: 'world' };
  render() {
    return this.props.render(this.state);
  }
}

// Transpiled
function HelloWorld() {
  return React.createElement(
    DataProvider,
    {
        render: function render(data) {
          return React.createElement(
            'h1',
            null,
            'Hello ',
            data.message
          );
        }
    }
  );
}

Example 2: Using children prop

class DataProvider extends React.Component {
  state = { message: 'world' };
  render() {
    return this.props.children(this.state);
  }
}

function HelloWorld() {
  return (
    <DataProvider>
      {(data) => <h1>Hello {data.message}</h1>}
    </DataProvider>
  );
}

Example 3: do something interesting

class FetchUser extends React.Component {
  state = { user: null };

  async componentDidMount() {
    const result = await fetch('/user');
    const user = await result.json();
    this.setState({ user });
  }

  render() {
    return this.props.children(this.state.user);
  }
}

function Greeting() {
  return (
    <FetchUser>
      {(user) => user ? <h1>Hello {user.firstName}</h1> : <div>Loading...</div>}
    </FetchUser>
  );
}

Example 4: Make it generic

class Fetch extends React.Component {
  state = { data: null };

  async componentDidMount() {
    const result = await fetch(this.props.url);
    const data = await result.json();
    this.setState({ data });
  }

  render() {
    return this.props.children(this.state.data);
  }
}

function Greeting() {
  return (
    <Fetch url="/user">
      {(user) => user ? <h1>Hello {user.firstName}</h1> : <div>Loading...</div>}
    </Fetch>
  );
}

What if you need a way to call back to the Smart Component

// non-render prop example
class SomeWrapper extends React.Component {
  handleUpdate = (message) => this.setState({ message });
  state = { message: 'world' };
  render() {
    return (
        <div>
            <ChildComponent
  		value={this.state.message}
  		onChange={this.handleUpdate}
	    />
  	</div>
    );
  }
}

How do we do this with Render Props?

How NOT to do it!

// What's the problem with this?
class DataProvider extends React.Component {
  handleUpdate = (message) => this.setState({ message });
  state = { message: 'world' };
  render() {
    return this.props.children({
      message: this.state.message,
      handleUpdate: this.handleUpdate,
    });
  }
}

What's wrong with this?

How to do this correctly

class DataProvider extends React.Component {
  handleUpdate = (message) => this.setState({ message });
  // note we assign the handler to the state object
  state = {
  	message: 'world',
  	handleUpdate: this.handleUpdate
  };
  render() {
    // pass the entire state object, not a NEW object
    return this.props.children(this.state);
  }
}

Problem with Render Props

export default class ConsumerOfRenderProps extends React.Component {
  componentDidMount() {
    // ARG!!! No access to render props data.message
  }
  render() {
    return(
        <SomeRenderPropDataProvider>
          {(data) => <h1>Hello {data.message}</h1>}
        </SomeRenderPropDataProvider>
    );
  }
}

Cannot access output of the render prop component within any lifecycle hooks

Solution 1: Wrap your Component

class ConsumerOfRenderProps extends React.Component {
  componentDidMount() {
  	console.log('Yay I can access:', this.props.message);
  }
  render() {
	return (
		<h1>Hello {this.props.message}</h1>
	);
  }
}

export default (props) => (
    <SomeRenderPropDataProvider>
      {(data) => <ConsumerOfRenderProps {...props} message={data.message} />}
    </SomeRenderPropDataProvider>
);

Note: the component that is exported is already wrapped in the Render Props provider

Solution 2: Export both HoCs and Render Props

export class DataProvider extends React.Component {
    handleUpdate = (message) => this.setState({ message });
    state = {
        message: 'world',
        handleUpdate: this.handleUpdate
    };
    render() {
        return this.props.children(this.state);
    }
}

export function withDataProvider(Component) {
    function WithDataProvider(props) {
        return (
            <DataProvider>
                (data) => <Component {...props} {...data} />
            </DataProvider>
        );
    }
    // Do all the other HoC best practices here :)
    return WithDataProvider;
}

HIgher order Components vs. Render props

React Context API

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Think of context data as global to your React app

Examples: Sharing authenticated user data, a UI theme, or preferred language

prop drilling 🤮

class App extends React.Component {
    state = {
        user: null,
        avatarSize: null
    }
    componentDidMount() {
        fetch('/user').then(data => this.setState({
            user: data.user,
            avatarSize: data.avatarSize
        }));
    }
    render() {
        return (
            <Page
		user={this.state.user}
		avatarSize={this.state.avatarSize}
	    />
	);
    }
}

...and then

const Page = (props) => (
    return (
        <main class="page">
	    <PageLayout
		user={props.user}
		avatarSize={props.avatarSize}
	    />
        </main>
    );
);

...and then

const PageLayout = (props) => (
    return (
        <div class="page-layout">
	    <NavigationBar
                user={props.user} avatarSize={props.avatarSize}
	    />
	    <SideBar />
	    <MainContent>{/* stuff */}</MainContent>
	</div>
    );
);

...and then

const NavigationBar = (props) => (
    return (
        <nav class="nav-bar">
            <MainNav>{/* stuff */}</MainNav>
	    <AvatarLink user={props.user} size={props.avatarSize} />
	</nav>
    );
);

...and finally

const AvatarLink = (props) => (
    return (
        <Link href={props.user.permalink}>
            <Avatar user={props.user} size={props.avatarSize} />
        </Link>
    );
);

Solved with React Context

class App extends React.Component {
    state = {
        user: null,
        avatarSize: null
    }
    componentDidMount() {
        fetch('/user').then(data => this.setState({
            user: data.user,
            avatarSize: data.avatarSize
        }));
    }
    render() {
        return (
            <UserContext.Provider value={this.state}>
                <Page />
            </UserContext.Provider>
        );
    }
}

Solved with React Context

const AvatarLink = (props) => (
    <UserContext.Consumer>
        {({ user, avatarSize }) => (
            <Link href={user.permalink}>
                <Avatar user={user} size={avatarSize} />
            </Link>
        )}
    </UserContext.Consumer>
);

How to create and use context

  1. Create the context. Export the Consumer and the Provider
  2. Wrap a high level component in your React app tree with the Provider. Set the Provider's value prop with the value to be shared with all Consumers
  3. For each component within the Provider's subtree that needs access to the value, wrap the component in the Consumer and extract the data via render props

1. Create the context

const value = {
  theme: themes.dark,
  toggleTheme: () => { /* no-op */ },
};

export const ThemeContext = React.createContext(value);

// <ThemeContext.Provider />
// <ThemeContext.Consumer />

2. Use the PRovider and set the value

import ThemeContext from './ThemeContext';
import Page from './Page';

export class App extends React.Component {
    handleToggleTheme = (theme) => this.setState({ theme });
    state = {
        theme: themes.dark,
        toggleTheme: this.handleToggleTheme,
    };
    render() {
        return (
	    <ThemeContext.Provider value={this.state}>
		<Page />
	    </ThemeContext.Provider>
	);
    }
}

3a. Use the consumer

import ThemeContext from './ThemeContext';

export const ThemeToggler = () => (
    <ThemeContext.Consumer>
        {({ toggleTheme }) => 
            <ButtonBar>
	        <Button onClick={() => toggleTheme(theme.dark)}>Dark</Button>
		<Button onClick={() => toggleTheme(theme.light)}>Light</Button>
	    </ButtonBar>
	}
    <ThemeContext.Consumer>
);

3b. Use the consumer

import ThemeContext from './ThemeContext';

export const ThemeToggler = () => (
    <ThemeContext.Consumer>
        {({ toggleTheme }) => 
            <ButtonBar>
	        <Button onClick={() => toggleTheme(theme.dark)}>Dark</Button>
		<Button onClick={() => toggleTheme(theme.light)}>Light</Button>
	    </ButtonBar>
	}
    <ThemeContext.Consumer>
);

Caveat

  • The React.createContext API was introduced in 16.3
  • There is a legacy (uglier) context that can be used for prior versions
  • Use the create-react-context polyfill for older React versions

Render Props: Separation of concerns

  • Model & Controller live within the render props provider component
  • You BYO view

Further reading & resources

Render Props and Context API

By Eric Masiello

Render Props and Context API

  • 288