Brian Neville-O'Neill
Posted on May 12, 2020
Written by Kasra Khosravi✏️
Custom Hooks
We are going to talk about some cool examples of custom React Hooks and build a resizable React component using them. If you are not familiar with the concept of Hooks, please review the Hook’s basic definitions and rules before continuing this article.
Hooks allow us to think in new ways. Now, we can group certain parts of React component’s logic related to a certain context (like fetch data or page events) into custom React Hooks. This is happening without the need to refactor the components hierarchy that needs to share a state with each other. Also, we do not need the verbosity of repeating and using unrelated logic in lifecycle methods.
Resizable React component example
We are going to build a simple React component together that uses some of the custom Hooks from beautiful-react-hooks
library. We will cover these custom Hooks individually and glue everything together in the end to build our component. As a learning exercise, we will also build these examples using React class and lifecycle methods to see what benefits we might gain by using Hooks.
As an example, this component would display a dynamic list of elements that get truncated, if their total list’s width is bigger than the current window’s width. In case the list gets truncated, we want to show the user how many remaining items are in the list. The final result could look something like this:
Let’s get started.
useGlobalEvent and useWindowResize
To build our component, we need a mechanism for listening and reacting to [resize event
[(https://developer.mozilla.org/en-US/docs/Web/API/Window/resize_event) in the context of global window object
. As it turns out, there is a very useful custom Hook called useGlobalEvent
which can help us. You just pass an event’s name and the Hook adds a listener for that event on the window object. It returns a handler setter (onWindowResize
in the below example) for it, which is immediately invoked.
Keep in mind that this handler should not be run asynchronously and it does not cause the component to re-render. We are making sure the component responds to resize
changes by setting new state in the component using useState
. This way, the handler setter which is a reference to the Hook will be called again, with a new windowWidth
state.
After the component is unmounted, we need to clean up after ourselves by removing the event listeners that were attached. But why is that?
Remember that after each re-render caused by setWindowWidth
and new windowWidth
, we are calling our Hook again. This will cause n
number of bindings to the resize event
which can cause memory leaks in our application. useGlobalEvent
takes care of this for us, by removing the event handler of new re-renders.
Here is an example of using useGlobalEvent
Hook:
// global dependencies
import * as React from "react";
import { useGlobalEvent } from "beautiful-react-hooks";
// initalization
const { useState } = React;
const App = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const onWindowResize = useGlobalEvent("resize");
onWindowResize((event: React.SyntheticEvent) => {
setWindowWidth(window.innerWidth);
});
return (
<div className="toast toast-primary">
Current window width: {windowWidth}
</div>
);
};
export default App;
Here is an example of another custom Hook useWindowResize
, built on top of useGlobalEvent
which makes the component even simpler:
// global dependencies
import * as React from "react";
import { useWindowResize } from "beautiful-react-hooks";
// initalization
const { useState } = React;
const App = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useWindowResize((event: React.SyntheticEvent) => {
setWindowWidth(window.innerWidth);
});
return (
<div className="toast toast-primary">
Current window width: {windowWidth}
</div>
);
};
export default App;
Here is the example using class and React lifecycle methods. This is a simple example, but you can see that the above custom React Hook takes care of cleaning up automatically before the next component re-render. This is something we need to cover in React lifecycle methods manually:
// global dependencies
import * as React from "react";
// interface
interface IProps {}
interface IState {
width?: number;
}
class App extends React.Component<IProps, IState> {
constructor(props: any) {
super(props);
this.state = {
width: window.innerWidth
};
}
// local methods
setWindowWidth = () => {
this.setState({
width: window.innerWidth
});
};
// lifecycle methods
componentDidMount() {
window.addEventListener("resize", this.setWindowWidth);
}
componentWillUnmount() {
window.removeEventListener("resize", this.setWindowWidth);
}
render() {
return (
<div className="toast toast-primary">
Current window width: {this.state.width}
</div>
);
}
}
export default App;
So far, we have managed to set a handler for the resize events
which will help us to build our component. But first, is there any optimization we can do for the above examples?
useDebouncedFn and useThrottleFn
You might have noticed that in the window resize example above, we are calling the setWindowWidth
for every resize
event that is handled in the event loop. We might need to handle setWindowWidth
less often which can gain us some rendering performance. We can do this with the help of useDebouncedFn
and useThrottleFn
, to delay the execution of setWindowWidth
function over time.
Debouncing
When talking debouncing the execution of a function, we are trying to batch multiple function calls into a single one to improve performance. In this way, when the user is changing the window’s width, we are making sure to batch all of the calls to the setWindowWidth
into a single one for every 0.25 seconds. If the resize events
are happening fast and rapidly, debouncing takes place; otherwise not (check the console.log
value in the sandbox below and compare it with throttle
example below).
Here is an example using this custom Hook:
// global dependencies
import * as React from "react";
import { useGlobalEvent, useDebouncedFn } from "beautiful-react-hooks";
// initalization
const { useState } = React;
const App = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const onWindowResize = useGlobalEvent("resize");
const onWindowResizeHandler = useDebouncedFn(() => {
console.log("I am debouncing", windowWidth);
setWindowWidth(window.innerWidth);
}, 250);
onWindowResize(onWindowResizeHandler);
return (
<div className="toast toast-primary">
Current window width: {windowWidth}
</div>
);
};
export default App;
Throttling
The throttling concept, even though it is similar to debounce
, has its differences. For example with throttle
, you do not allow the execution of setWindowWidth
more than once every 0.25 seconds. However, the regular execution of function is guaranteed every 0.25 seconds.
Check this scenario by checking the console.log
in the below example:
// global dependencies
import * as React from "react";
import { useGlobalEvent, useThrottledFn } from "beautiful-react-hooks";
// initalization
const { useState } = React;
const App = () => {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const onWindowResize = useGlobalEvent("resize");
const onWindowResizeHandler = useThrottledFn(() => {
console.log("I am throttling", windowWidth);
setWindowWidth(window.innerWidth);
}, 250);
onWindowResize(onWindowResizeHandler);
return (
<div className="toast toast-primary">
Current window width: {windowWidth}
</div>
);
};
export default App;
Finally, let’s see debouncing
in the context of using lifecycle methods. We are gonna use lodash.debounce
. All we need to do is to debounce
our call to setWindowWidth
when listening to resize event
in componentDidMount
:
import _debounce from "lodash.debounce";
componentDidMount() {
window.addEventListener(
"resize",
_debounce(() => {
this.setWindowWidth();
}, 250)
);
}
Here is the full example:
// global dependencies
import * as React from "react";
import _debounce from "lodash.debounce";
// interface
interface IProps {}
interface IState {
width?: number;
}
class App extends React.Component<IProps, IState> {
constructor(props: any) {
super(props);
this.state = {
width: window.innerWidth
};
}
// local methods
setWindowWidth = () => {
this.setState({
width: window.innerWidth
});
};
// lifecycle methods
componentDidMount() {
window.addEventListener(
"resize",
_debounce(() => {
this.setWindowWidth();
}, 250)
);
}
componentWillUnmount() {
window.removeEventListener("resize", this.setWindowWidth);
}
render() {
return (
<div className="toast toast-primary">
Current window width: {this.state.width}
</div>
);
}
}
export default App;
Final result
So far, we have set a debounced handler to listen for resize events
and set the windowWidth
state. Now, we are gonna bring everything together to build the resizable React component we have described in the beginning of the article. A few things to note:
- The number of items we want to show is dynamic, meaning that it will be different on each initial render. This would require us to show a certain number of items in a row after debounced
resize events
are fired - We utilize some of the Hooks that are already integrated into React library (from 16.8). You are probably already familiar with them, but here is a short description from the official documentation:
- We use mocked
dynamicData
and its total number to mimic the behaviour of an API call:
// helpers
const integerGenerator = (n: number) => Math.ceil(Math.random() * n);
// faking a dynamic data count which in real life
// scenario would come from an api endpoint
const dynamicDataCount = integerGenerator(100);
// data mocks
const mockedData = () => {
const data = [];
for (let i = 0; i < dynamicDataCount; i++) {
const image : any = (
<figure className="avatar mr-2" data-initial="...">
<img src="https://picturepan2.github.io/spectre/img/avatar-1.png" alt="YZ" />
</figure>
);
data.push(image);
};
return data;
};
// this would generate an array of mockedData
// elements with a length of random dynamicDataCount
mockedData();
- We built a
handleView
function to calculate the total number of items that are possible to be shown. If their total width exceedswindow width
, we attach a new element to the list of items which shows how many items are hidden from view. If not, we just return the list of items. The idea of this helper was formed after reading this article. Make sure to check it out for another perspective:
const handleView = (items: Array<Element>) => {
// 8 is the value of margin right applied to image elements (8px)
var maxItemsToShow = Math.floor(windowWidth / (elementWidth + 8));
// return current view if total number of items is less than maximum possible
// number of items that can be shown based on the current window width
if (items.length <= maxItemsToShow) {
return items;
}
// if not, we need a new element which shows how many more items are in the list that are now shown
const moreDataPlaceholder = 1;
const numberOfRemainingItems = items.length - maxItemsToShow + moreDataPlaceholder;
const truncatedItems = items.slice(0, maxItemsToShow - moreDataPlaceholder);
const displayNumberHtml : any = (
<figure className="avatar badge" data-badge={numberOfRemainingItems} data-initial="..." />
);
truncatedItems.push(displayNumberHtml);
return truncatedItems;
}
The final code looks something like this:
// global dependencies
import * as React from "react";
import { useGlobalEvent, useDebouncedFn } from "beautiful-react-hooks";
// initalization
const { useState, useRef, useEffect } = React;
// helpers
const integerGenerator = (n: number) => Math.ceil(Math.random() * n);
// faking a dynamic data count which in real life
// scenario would come from an api endpoint
const dynamicDataCount = integerGenerator(100);
// data mocks
const mockedData = (ref: any) => {
const data = [];
for (let i = 0; i < dynamicDataCount; i++) {
const image : any = (
<figure ref={ref} className="avatar mr-2" data-initial="...">
<img src="https://picturepan2.github.io/spectre/img/avatar-1.png" alt="YZ" />
</figure>
);
data.push(image);
};
return data;
};
const App = () => {
// component initialization
const ref = useRef<HTMLInputElement>(null);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const [elementWidth, setElementWidth] = useState(0);
const onWindowResize = useGlobalEvent("resize");
// handler for initially calculating individual elements width
useEffect(() => {
const width = ref.current ? ref.current.offsetWidth : 0;
setElementWidth(width);
}, []);
// handler for calculating window width on resize event
const onWindowResizeHandler = useDebouncedFn(() => {
setWindowWidth(window.innerWidth);
}, 250);
onWindowResize(onWindowResizeHandler);
const handleView = (items: Array<Element>) => {
// 8 is the value of margin right applied to image elements (8px)
var maxItemsToShow = Math.floor(windowWidth / (elementWidth + 8));
// return current view if total number of items is less than maximum possible
// number of items that can be shown based on the current window width
if (items.length <= maxItemsToShow) {
return items;
}
// if not, we need a new element which shows how many more items are in the list that are now shown
const moreDataPlaceholder = 1;
const numberOfRemainingItems = items.length - maxItemsToShow + moreDataPlaceholder;
const truncatedItems = items.slice(0, maxItemsToShow - moreDataPlaceholder);
const displayNumberHtml : any = (
<figure className="avatar badge" data-badge={numberOfRemainingItems} data-initial="..." />
);
truncatedItems.push(displayNumberHtml);
return truncatedItems;
}
return (
<div className="toast toast-primary px-0 mx-0">
{handleView(mockedData(ref)).map((element : Element) => element)}
</div>
);
};
export default App;
Now it is time to see this example, using class and lifecycle method. At first glance, you see the lifecycle methods like componentDidMount
gets a bit more complicated. It is because the logic of class components is about grouping side effect management in different phases of component lifecycle, rather than basing them on individual effects (like setting the window width
and individual element width
):
// global dependencies
import * as React from "react";
import _debounce from "lodash.debounce";
// helpers
const integerGenerator = (n: number) => Math.ceil(Math.random() * n);
// faking a dynamic data count which in real life
// scenario would come from an api endpoint
const dynamicDataCount = integerGenerator(100);
// data mocks
const mockedData = (ref: any) => {
const data = [];
for (let i = 0; i < dynamicDataCount; i++) {
const image: any = (
<figure ref={ref} className="avatar mr-2" data-initial="...">
<img
src="https://picturepan2.github.io/spectre/img/avatar-1.png"
alt="YZ"
/>
</figure>
);
data.push(image);
}
return data;
};
// interface
interface IProps {}
interface IState {
windowWidth?: number;
elementWidth?: number;
}
class App extends React.Component<IProps, IState> {
private ref = React.createRef<HTMLDivElement>();
constructor(props: any) {
super(props);
this.state = {
windowWidth: window.innerWidth,
elementWidth: 0
};
}
// local methods
setWindowWidth = () => {
this.setState({
windowWidth: window.innerWidth
});
};
setElementWidth = (elementWidth: number) => {
this.setState({
elementWidth: elementWidth
});
};
// lifecycle methods
componentDidMount() {
const elementWidth = this.ref.current ? this.ref.current.offsetWidth : 0;
this.setElementWidth(elementWidth);
window.addEventListener(
"resize",
_debounce(() => {
this.setWindowWidth();
}, 250)
);
}
componentWillUnmount() {
window.removeEventListener("resize", this.setWindowWidth);
}
handleView = (items: Array<Element>) => {
// 8 is the value of margin right applied to image elements (8px)
let maxItemsToShow = 0;
if (this.state.windowWidth && this.state.elementWidth) {
maxItemsToShow = Math.floor(
this.state.windowWidth / (this.state.elementWidth + 8)
);
}
// return current view if total number of items is less than maximum possible
// number of items that can be shown based on the current window width
if (items.length <= maxItemsToShow) {
return items;
}
// if not, we need a new element which shows how many more items are in the list that are now shown
const moreDataPlaceholder = 1;
const numberOfRemainingItems =
items.length - maxItemsToShow + moreDataPlaceholder;
const truncatedItems = items.slice(0, maxItemsToShow - moreDataPlaceholder);
const displayNumberHtml: any = (
<figure
className="avatar badge"
data-badge={numberOfRemainingItems}
data-initial="..."
/>
);
truncatedItems.push(displayNumberHtml);
return truncatedItems;
};
render() {
return (
<div className="toast toast-primary px-0 mx-0">
{this.handleView(mockedData(this.ref)).map(
(element: Element) => element
)}
</div>
);
}
}
export default App;
Conclusion
Let’s review what we have learned together:
- Together we built a simple React component that adapts to different window widths sizes and shows a dynamic number of items. We also learned how to optimize this process by delaying function calls to our event handlers.
- We saw, in action, how Hooks can make building components easier and how custom Hooks can make that even smoother. But changing direction and deciding to write or re-write components using Hooks is not very straightforward. Before making any decision, make sure to read React’s official adaptation guide. And remember to experiment more with this new concept to get more informed about its advantages and disadvantage.
References
https://reactjs.org/docs/hooks-intro.html
https://github.com/beautifulinteractions/beautiful-react-hooks
https://css-tricks.com/debouncing-throttling-explained-examples/
https://www.pluralsight.com/guides/re-render-react-component-on-window-resize
https://medium.com/hootsuite-engineering/resizing-react-components-6f911ba39b59
Full visibility into production React apps
Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your React apps — start monitoring for free.
The post Building resizable React component using custom React Hooks appeared first on LogRocket Blog.
Posted on May 12, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 27, 2024