Are you tired of refactoring stateless-functional components to class-based ones, just to manage simple local state? Have you heard about React Hooks, but not sure how to use them in your current codebase? Then this article is for you!
JavaScript and its ecosystem are evolving at a rapid pace. As new frameworks, libraries, and features get added JavaScript fatigue can start to set in.
It can be difficult to know what's worth adopting, when to adopt it, and how to introduce it into our existing projects.
While adopting some things may require large refactors or even migration to a new framework, other changes can be incrementally added to your existing projects.
Class-based or Functional?
React allows us to build both class-based and functional components.
Generally, it's recommended to start with a functional component for a few key reasons, explained by David Jöch:
1. Functional component are much easier to read and test because they are plain JavaScript functions without state or lifecycle-hooks
2. You end up with less code
3. They help you to use best practices. It will get easier to separate container and presentational components because you need to think more about your component’s state if you don’t have access to setState() in your component
4. The React team mentioned that there may be a performance boost for functional component in future React versions
We're not going to go into the details of that here, and that's not to say that class-based components don't have their benefits.
When following the recommended approach and starting with most of our components being functional - what happens when you need to keep track of some internal state, or be able to take advantage of React lifecycle methods such as componentDidMount or componentWillUpdate?
Currently you would need to convert it to a Class-based component.
But not for much longer.
React Hooks are coming to save the day!
Class Dismissed
At Rangle, we've been exploring the potential of React Hooks, so I reached out to coworkers to see if they had any examples of existing code they thought could benefit from Hooks.
Harry Nicholls sent me an example from a current project he's working on. The scenario he described is that the team had:
A stateless-functional component that was refactored to a class-based component just so we could track the state of the Sidebar (to open and close it via a button, and open it after navigating to a new page).
We could definitely make use of useState here, and probably useEffect too.
There are other components that have a similar toggleExpanded functionality, so it could even be extracted into a utility and imported where needed.
This sounded like a great scenario for using hooks. With a bit of code removed for clarity, the initial functional version is below:
export const Layout = ({ children, pageName, router }) => {
const currentPath = stripLangFromPath(router.pathname);
const sectionRoutes = getCategorisedRoutesForSection(router.pathname);
return (
<div>
<MainNav />
<div>
<Sidebar pathname={currentPath} />
</div>
<Page name={pageName}>
{children}
{sectionRoutes.uncategorised.length > 1 && (
<NextPage pathname={currentPath} sectionRoutes={sectionRoutes.uncategorised} />
)}
</Page>
<div>
<Footer />
</div>
</div>
);
};
Requirements changed, the component then needed to know whether the Sidebar was expanded or not, and needed to listen to events from the router. It was refactored into a class component so it could have state and access to the componentDidMount lifecycle hook.
Harry ended up with this class-based component:
export class Layout extends React.Component {
constructor() {
super();
this.state = {
isSidebarExpanded: true,
};
}
componentDidMount() {
Router.events.on('routeChangeComplete', () => {
this.setState({
isSidebarExpanded: true,
});
});
}
toggleSidebar = isSidebarExpanded => {
this.setState({
isSidebarExpanded: !isSidebarExpanded,
});
};
render() {
const { children, pageName, router } = this.props;
const { isSidebarExpanded } = this.state;
const currentPath = stripLangFromPath(router.pathname);
const sectionRoutes = getCategorisedRoutesForSection(router.pathname);
return (
<div>
<MainNav />
<div>
<Sidebar
pathname={currentPath}
toggleSidebar={this.toggleSidebar}
isExpanded={isSidebarExpanded}
/>
</div>
<Page name={pageName}>
{children}
<NextPage pathname={currentPath} sectionRoutes={sectionRoutes.uncategorised} />
</Page>
<div>
<Footer />
</div>
</div>
);
}
}
The component is now significantly bigger, just because of the small change in requirements.
This is a scenario where Hooks would be a good fit, and an easy way to start to leverage new features of React without needing a large-scale refactor effort.
But how can I identify similar scenarios in my own projects?
First think about how the change came about, and what are the additional requirements really asking you to do?
In Harry's case, the change was driven by these additional requirements:
- Keep track of whether or not the sidebar is expanded
- Respond to router events once the component mounts
Two things should jump out here:
- Keeping track of something means adding state
- Responding to events requires a subscription
Considering these, we can then think about which hooks would be appropriate to use:
- useState for tracking the state of the sidebar
- useEffect for the router event subscription and cleanup
Here's what the functional Layout component could look like with those hooks:
export function Layout(props) {
const [isSidebarExpanded, setSideBarExpanded] = useState(true);
function handleRouteChange(url) {
setSideBarExpanded(true);
}
useEffect(function routeChangeComplete() {
Router.events.on('routeChangeComplete', handleRouteChange);
return () => Router.events.off('routeChangeComplete', handleRouteChange);
},[]);
const toggleSidebar = () => setSideBarExpanded(!isSidebarExpanded);
const { children, pageName, router } = props;
const currentPath = stripLangFromPath(router.pathname);
const sectionRoutes = getCategorisedRoutesForSection(router.pathname);
return (
<div>
<MainNav />
<div>
<Sidebar
pathname={currentPath}
sectionRoutes={sectionRoutes}
toggleSidebar={toggleSidebar}
isExpanded={isSidebarExpanded}
/>
</div>
<Page name={pageName}>
{children}
<NextPage
pathname={currentPath}
sectionRoutes={sectionRoutes.uncategorised}
/>
)}
</Page>
<div>
<Footer />
</div>
</div>
);
}
An eagle-eyed code reviewer may have noticed a potential bug in the initial class based version - we're not cleaning up the subscription when the component unmounts.
It's easy to overlook this when your subscription code and cleanup code are split between two different lifecycle methods such as:
- componentDidMount
- componentWillUnmount
With the useEffect hook, your cleanup code can live right next to your subscription code.
Custom Hooks
One of the benefits when we start keeping related code close together like this, is being able to notice which common patterns emerge.
In this case, wanting to run a function when a router event happens, and clean up the subscription when the component unmounts.
This could be generalized into a custom hook useOnRouterEvent:
// utils/hooks.js
function useOnRouterEvent(event, cb) {
useEffect(function handleEvent() {
Router.events.on(event, cb);
return () => Router.events.off(event, cb);
},[]);
}
// Layout.js
function Layout(props) {
const [isSidebarExpanded, setSideBarExpanded] = useState(true);
function handleRouteChange(url) {
setSideBarExpanded(true);
}
useOnRouterEvent('routeChangeComplete', handleRouteChange);
/* ...... */
}
We could use an ()⇒ arrow function in the useEffect handler, however using named functions will help keep things easier to debug, and show the function name in the React Devtools once they support Hooks.
Extracting Reusable Behaviour
Other components in the library also need similar toggle functionality. The common requirement is to keep track of whether a component is expanded or not, while being able to explicitly set it it's state, with the option to simply toggle it.
We can extract this behaviour into another custom, reusable hook, useToggle:
function useToggle(initialState) {
const [isExpanded, setExpanded] = useState(initialState);
function toggleExpanded() {
setExpanded(!isExpanded);
}
// We probably still want to expose 'setExpanded' so we can explicitly set the state
return [isExpanded, setExpanded, toggleExpanded];
}
We can then leverage this hook in the Layout component as we discussed above:
function Layout(props) {
const [isSidebarExpanded, setSidebarExpanded, toggleSidebar] = useToggle(true);
function handleRouteChange(url) {
setSidebarExpanded(true);
}
useOnRouterEvent('routeChangeComplete', handleRouteChange);
{/* ...... */}
return (
<div>
<MainNav />
<div>
<Sidebar
pathname={currentPath}
sectionRoutes={sectionRoutes}
toggleSidebar={toggleSidebar}
isExpanded={isSidebarExpanded}
/>
</div>
{/* ...... */}
);
}
The concept of a toggle can apply to many situations, so we looked for another component in the project that had a similar need. One such component was the PropsTable, that needs to toggle the expanded state.
This is another case, where a functional component was turned into a class based one just for a simple bit of state.
Class-based PropsTable:
export class PropsTable extends React.Component {
constructor(props) {
super(props);
this.state = {
isExpanded: false,
propsRows: this.getPropsRows(props.components),
};
}
getPropsRows(components) {
{ /* ...... */ }
}
toggleExpanded() {
this.setState(prevState => ({
isExpanded: !prevState.isExpanded,
}));
}
render() {
const { collapseTo } = this.props;
const { propsRows, isExpanded } = this.state;
const isExpandable = collapseTo >= 0 && propsRows.length > collapseTo;
const displayedPropRows = isExpandable && !isExpanded ? propsRows.slice(0, collapseTo) : propsRows;
const showMoreText = isExpanded ? 'Show Less' : 'Show More';
const showMoreIcon = isExpanded ? 'chevron-up' : 'chevron-down';
return (
<Table>
{ /* ..... */ }
<tbody>
{displayedPropRows.map(row => (
<Prop prop={row} />
))}
</tbody>
<tfoot>
<tr>
<td colSpan="3">
<Button element="button" variant="moreless" onClick={this.toggleExpanded}>
{showMoreText}
<FontAwesomeIcon icon={showMoreIcon} />
</Button>
</td>
</tr>
</tfoot>
</Table>
);
}
}
With hooks, we are able to keep this as a functional component, and easily reuse code that is based on behaviour, not UI.
And here it is as a functional component with the custom useToggle hook:
export function PropsTable(props) {
const [isTableExpanded, , toggleExpanded] = useToggle(false); // Skipped 'setExpanded' function as it's not used here
function getPropsRows(components) {
{ /* ...... */ }
}
const { collapseTo } = props;
const propsRows = getPropsRows(props.components);
const isExpandable = collapseTo >= 0 && propsRows.length > collapseTo;
const displayedPropRows = isExpandable && !isTableExpanded ? propsRows.slice(0, collapseTo) : propsRows;
const showMoreText = isTableExpanded ? 'Show Less' : 'Show More';
const showMoreIcon = isTableExpanded ? 'chevron-up' : 'chevron-down';
return (
<Table>
{/* ... */}
<tbody>
{displayedPropRows.map(row => (
<Prop prop={row} />
))}
</tbody>
<tfoot>
<tr>
<td colSpan="3">
<Button element="button" variant="moreless" onClick={toggleExpanded}>
{showMoreText}
<FontAwesomeIcon icon={showMoreIcon} />
</Button>
</td>
</tr>
</tfoot>
</Table>
);
}
Is there a Hook for that?
Hopefully this post has shown you how Hooks will allow you to track state and access lifecycle hooks in a functional component.
Soon, you won't be forced to use class-based components if you need to do these things. You'll be able to add built-in React Hooks to your functional component, and create custom, reusable Hook utilities for commonly used functionalities.
If you want to start identifying areas for improvement now, and get some easy wins, look out for simple, class-based components in your codebase that track small pieces of local state, or subscribe to events.
And next time you find yourself about to change a functional component to a class-based component, ask yourself:
Is there a Hook for that?
If there isn't, could you create one?
Keep your eyes on the Rangle.io blog, as we'll continue to find and share examples like this to demonstrate how your existing codebase can benefit from Hooks.
If your as excited about the upcoming features in React as we are, consider joining our team and check out current openings