Code Reuse with React Hooks
One of the most challenging problems for frontend developers is the reuse of code. It’s especially important for big applications. Traditional approaches with higher-order components and render-props have advantages and disadvantages. In some cases, it can be inconvenient to use them. React 16.8 introduced a new feature called React Hooks. Let’s take a look at it and try to figure out how does it work and how does it solve the problem of code reuse.
Problem of code reuse
During the software development process of frontend project, every developer meets code reuse problem sooner or later. This code can be stateless or stateful. A typical example of stateless shared code is date formatting function. It can be used like this:
import { formatDate } from 'utils';
function Comment(props) {
return (
<div className="comment">
<div className="createdAt">{formatDate(props.createdAt)}</div>
<p>{props.text}</p>
</div>
);
}
It’s easy to use stateless shared code because you don’t need to think about the state, so it can be moved into pure functions.
A More difficult situation is when you need some state. E.g. several components need the current user name and array of comments. The logic of data fetching is the same and we want to share it between all components.
Let’s compare different approaches to solving this problem.
Render props
Render props is a technique for sharing the code between React components using a prop whose value is a function. Such an approach makes it possible to separate logic and rendering into two different components.
class UserProvider extends React.Component {
state = { user: null };
async componentDidMount() {
const user = await fetchUser();
this.setState({ user });
}
render() {
return this.props.render(this.state.user);
}
}
function App(user) {
return (
<p>
Current user: {user ? user.name : 'anonymous'}
</p>
);
}
ReactDOM.render(<UserProvider render={App} />, document.getElementById('app'));
In the example above user fetching extracted into separate UserProvider
component. This component
takes a function as a render
prop that returns a React element and calls it instead of
implementing its own render logic, so different components can reuse UserProvider
.
If some component needs two different render props components it can be implemented like this:
class UserProvider extends React.Component {
state = { user: null };
async componentDidMount() {
const user = await fetchUser();
this.setState({ user });
}
render() {
return this.props.render(this.state.user);
}
}
class CommentsProvider extends React.Component {
state = { comments: [] };
async componentDidMount() {
const comments = await fetchComments();
this.setState({ comments });
}
render() {
return this.props.render(this.state.comments);
}
}
function App(user, comments) {
return (
<>
<p>Current user: {user ? user.name : "anonymous"}</p>
<div className="comments">
{comments.map(c => (
<div className="comment">{c.text}</div>)
)}
</div>
</>
);
}
ReactDOM.render(
<UserProvider
render={user => (
<CommentsProvider render={comments => (
App(user, comments)
)} />;
)}
/>,
document.getElementById("app")
);
As you can see, this code looks a bit dirty.
Higher-Order Components
Higher-order components (HOC) is a function that takes a component and returns a new component. Such a technique can be used for extracting logic into a wrapper-component and reuse it later in several places.
function withUser(WrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.state = { user: null };
}
async componentDidMount() {
const user = await fetchUser();
this.setState({ user });
}
render() {
return (
<WrappedComponent {...this.props} user={this.state.user} />
);
}
}
}
function App({ user }) {
return (
<p>
Current user: {user ? user.name : 'anonymous'}
</p>
);
}
const WrappedApp = withUser(App);
ReactDOM.render(<WrappedApp />, document.getElementById('app'));
In the example above user fetching extracted into separate component returned by withUser
function. This component wraps the original App
component and provides user
property for it.
Any component can be wrapped by several HOCs to connect different logic:
function withUser(WrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.state = { user: null };
}
async componentDidMount() {
const user = await fetchUser();
this.setState({ user });
}
render() {
return (
<WrappedComponent {...this.props} user={this.state.user} />
);
}
}
}
function withComments(WrappedComponent) {
return class extends React.Component {
constructor(props) {
super(props);
this.state = { comments: [] };
}
async componentDidMount() {
const comments = await fetchComments();
this.setState({ comments });
}
render() {
return (
<WrappedComponent {...this.props} comments={this.state.comments} />
);
}
}
}
function App({ user, comments }) {
return (
<>
<p>Current user: {user ? user.name : "anonymous"}</p>
<div className="comments">
{comments.map(c => (
<div className="comment">{c.text}</div>)
)}
</div>
</>
);
}
const WrappedApp = withComments(withUser(App));
ReactDOM.render(<WrappedApp />, document.getElementById('app'));
I think such chain withCommens(withUser(App))
looks much better than render props variant.
All wrapper-components are displayed in React Debugger:
For the long chains, it can be difficult to inspect and debug such tree but I think if an application contains such chains maybe its structure needs to be redesigned.
React Hooks
Before React 16.8 there were two types of components: stateless functional components and stateful class components. In React 16.8 Hooks were introduced. As it is said in the official documentation:
If you write a function component and realize you need to add some state to it, previously you had to convert it to a class. Now you can use a Hook inside the existing function component.
So the border between stateful and stateless components is blurring.
function useUser() {
const [user, setUser] = useState(null);
useEffect(async () => {
const u = await fetchUser();
setUser(u);
}, []);
return user;
}
function App() {
const user = useUser();
return (
<p>
Current user: {user ? user.name : 'anonymous'}
</p>
);
}
ReactDOM.render(<App />, document.getElementById('app'));
Before React Hooks it was a rule that functional components do not have any state but now it’s not
true. React provides some sort of external context for every functional component and React Hooks
use this context to store state and perform some effects like componentDidMount
hook does for
class components. I suggest reading
react fiber architecture description to
understand better how React Hooks is implemented.
In the example above custom hook useUser
was introduced. It encapsulates user state and makes it
possible to use it in App
functional component. Several custom hooks can be used in one functional
component:
function useUser() {
const [user, setUser] = useState(null);
useEffect(async () => {
const u = await fetchUser();
setUser(u);
}, []);
return user;
}
function useComments() {
const [comments, setComments] = useState([]);
useEffect(async () => {
const c = await fetchComments();
setComments(c);
}, []);
return user;
}
function App() {
const user = useUser();
const comments = useUser();
return (
<>
<p>Current user: {user ? user.name : "anonymous"}</p>
<div className="comments">
{comments.map(c => (
<div className="comment">{c.text}</div>)
)}
</div>
</>
);
}
ReactDOM.render(<App />, document.getElementById('app'));
The example with hooks requires less code and it seems that it is easier to read but there are some disadvantages to this approach.
First of all, it’s more difficult to understand how all this stuff works. When you use class
components internal data is stored in this.state
variable and the only source of external data is
props. It’s easy to understand such code works and easy to debug it because all your internal data
encapsulated in your instance. When you use hooks you have to know that your function is being
executed in some special React context and Hooks can store and extract data using some sort of
magic, you need to remember Rules of Hooks to write
code without errors.
Also, it’s more difficult to test your code with React Hooks. Assume you want to check rendering logic. In case of render props or HOC you just provide some attributes for your presentational component and check the result:
function App({ user, comments }) {
return (
<>
<p>Current user: {user ? user.name : "anonymous"}</p>
<div className="comments">
{comments.map(c => (
<div className="comment">{c.text}</div>)
)}
</div>
</>
);
}
it('Renders "anonymous" if user prop is null', () => {
const app = shallow(<App user={null} comments={[]} />);
expect(app.text()).to.contain('anonymous');
});
In case of React Hooks, you have to mock fetchUser
and fetchComments
function somehow. Sometimes
it can tricky and moreover you check not only rendering logic but custom hooks logic too.
Conclusion
I think React Hooks is a tricky feature which has advantages and disadvantages. Sometimes it can be useful and sometimes it can make your code harder to understand, so think twice before integrate it into your project.