Building resizable React component using custom React Hooks

bnevilleoneill

Brian Neville-O'Neill

Posted on May 12, 2020

Building resizable React component using custom React Hooks

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:

Image Source: Assets in https://picturepan2.github.io/spectre/
Image Source: Assets in https://picturepan2.github.io/spectre/

Let’s get started.

LogRocket Free Trial Banner

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
    );
  }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:
    • useState returns a stateful value, and a function to update it
    • useEffect accepts a function that contains imperative, possibly effectful code
    • useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue)
  • 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();
Enter fullscreen mode Exit fullscreen mode
  • We built a handleView function to calculate the total number of items that are possible to be shown. If their total width exceeds window 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;
  }
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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.

💖 💪 🙅 🚩
bnevilleoneill
Brian Neville-O'Neill

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