Smart Components in React
Part 2: Render Prop and the Context API
Agenda
- Recap Smart Components
- Render Props
- 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
- See Michael Jackson’s “Never write another render prop”
- Awesome React Render Props Repo
- Render props won’t work for everything. Thankfully, you can make HoCs from 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
- Create the context. Export the Consumer and the Provider
- 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
- 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