How to Make Your React Apps Responsive with a Custom Hook

Building responsive user interfaces is an essential part of modern web development. Users expect apps to look great and function well on a variety of devices and screen sizes. While CSS media queries have traditionally been the go-to tool for implementing responsive designs, they have some limitations. Fortunately, React‘s component-based architecture and hooks API provide a powerful alternative.

In this article, we‘ll explore how to use a custom React hook to make your app responsive in a more declarative and reusable way. By the end, you‘ll have a solid understanding of the technique and a practical useWindowSize hook you can drop into your own projects. Let‘s get started!

The Trouble with CSS Media Queries

Media queries allow you to define sets of CSS rules that only apply when certain conditions about the viewport are met. For example, you can specify different font sizes, margins, padding, and so on based on the screen width. Here‘s what a typical media query looks like:

/* Default styles */
h1 {
  font-size: 2rem;
}

/* Styles for screens up to 500px wide */
@media (max-width: 500px) { 
  h1 {
    font-size: 1.5rem;
  }
}

At first glance this seems great. Your styles automatically adjust themselves based on the environment. So what‘s the problem?

The main issue is that media queries are static. The breakpoints are hardcoded in your CSS and can‘t be changed dynamically based on the content or application state. Your React components are unaware of the current responsive mode, so you end up with presentation details leaking into your business logic as you try to sync the two worlds.

There‘s also no easy way to share responsive breakpoints between CSS and JavaScript. If you need to conditionally render a component or update a value in your app based on the viewport size, you have to duplicate those media query conditions in your JS. This violates the DRY principle and makes your code harder to maintain.

A Better Way: Responsive React Hooks

Instead of relying only on media queries, we can use React hooks to make our components responsive to the viewport size. The idea is to create a custom hook that:

  1. Detects the current window dimensions
  2. Exposes those values to your React components
  3. Updates the values whenever the window is resized

Your components can then import this hook and use the width and height values to conditionally render different views, update styles, or change behavior based on the current viewport size. Let‘s walk through how to implement this.

Creating a useWindowSize Hook

First, create a new file for the custom hook — something like useWindowSize.js. Here‘s the basic outline:

import { useState, useEffect } from ‘react‘;

function useWindowSize() {
  // Detect current window size
  // Update state when window resizes
  // Clean up event listener
  // Return current window size
}

export default useWindowSize;

The useState and useEffect hooks from React will allow us to manage the window size state and set up an event listener to detect changes.

Detecting the Initial Window Size

Inside the hook, we‘ll start by detecting the current window size when the hook first runs. We can do this by accessing the window.innerWidth and window.innerHeight properties:

function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });
  // ...
}

This sets up a state variable called size with width and height properties reflecting the current window dimensions. We initialize it by directly accessing the window size values.

However, there‘s one problem with this. When rendering on the server (SSR), there is no window global object. So if we leave the code like this, it will throw an error when used in a server-rendered app.

To fix that, we need to check if we‘re running in a browser environment before accessing window. Here‘s the updated code:

function useWindowSize() {
  const isSSR = typeof window === ‘undefined‘;
  const [size, setSize] = useState({
    width: isSSR ? 0 : window.innerWidth,
    height: isSSR ? 0 : window.innerHeight,
  });
  // ...
}

Now the initial state will be set to { width: 0, height: 0 } when rendered on the server, avoiding any errors. The real window size will be populated as soon as the component mounts in the browser.

Updating on Window Resize

Next, we need to ensure the size state updates whenever the window is resized. We can do this by attaching an event listener in a useEffect callback:

function useWindowSize() {
  const isSSR = typeof window === ‘undefined‘;
  const [size, setSize] = useState({
    width: isSSR ? 0 : window.innerWidth,
    height: isSSR ? 0 : window.innerHeight,
  });

  useEffect(() => {
    if (!isSSR) {
      function updateSize() {
        setSize({
          width: window.innerWidth,
          height: window.innerHeight,
        });
      }
      window.addEventListener(‘resize‘, updateSize);
    }
  }, [isSSR]);

  // ...
}

Inside the useEffect callback, we first check again that we‘re not in an SSR environment using the isSSR variable. This is necessary because the window object still won‘t be available at the time effects run on the initial server render.

If we are in a browser, we define an updateSize function that will be called every time the window resize event fires. This updates our size state to reflect the new window dimensions.

Lastly, we attach this function as an event listener on the window resize event.

Cleaning Up the Event Listener

You might have noticed that we‘re setting up an event listener, but never cleaning it up. This is a potential memory leak, as the listener will stick around even if the component that uses our useWindowSize hook unmounts.

To address this, we need to return a cleanup function from our useEffect callback:

useEffect(() => {
  if (!isSSR) {
    function updateSize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    window.addEventListener(‘resize‘, updateSize);

    return () => window.removeEventListener(‘resize‘, updateSize);
  }
}, [isSSR]);

Now React will automatically call this returned function to remove the event listener when the component unmounts, preventing any memory leaks.

Returning the Window Size

The final step is to return the current window size from our custom hook:

function useWindowSize() {
  // ...

  return size;
}

And with that, our useWindowSize hook is complete! Here‘s the full code:

import { useState, useEffect } from ‘react‘;

function useWindowSize() {
  const isSSR = typeof window === ‘undefined‘;
  const [size, setSize] = useState({
    width: isSSR ? 0 : window.innerWidth,
    height: isSSR ? 0 : window.innerHeight,
  });

  useEffect(() => {
    if (!isSSR) {
      function updateSize() {
        setSize({
          width: window.innerWidth,
          height: window.innerHeight,
        });
      }
      window.addEventListener(‘resize‘, updateSize);
      return () => window.removeEventListener(‘resize‘, updateSize);
    }
  }, [isSSR]);

  return size;
}

export default useWindowSize;

Now any component can easily import and use this hook to get the current window size and re-render whenever it changes.

Using the useWindowSize Hook

To demonstrate how to use the useWindowSize hook, let‘s build a simple responsive header component. We want to display a row of navigation links on larger screens, but collapse them behind a hamburger menu button on smaller screens.

Here‘s the code for the Header component:

import React from ‘react‘;
import useWindowSize from ‘./useWindowSize‘;

function Header() {
  const { width } = useWindowSize();

  return (
    <header>
      <div>My App</div>
      {width > 500 ? (
        <nav>
          <a href="/">Home</a>
          <a href="/about">About</a>
          <a href="/contact">Contact</a>
        </nav>
      ) : (
        <button>Menu</button>
      )}
    </header>
  );
}

export default Header;

First, we import the useWindowSize hook and destructure the width property from the returned size object.

Then in the JSX, we use a ternary operator to conditionally render either the full navigation menu or a hamburger button, based on the current window width. If width is greater than 500, we show the <nav> with the links. Otherwise, we display the <button>.

Now as the window is resized, the header will automatically switch between the expanded and collapsed states. No CSS media queries needed!

But what if we want to change the styles based on the window size? We can use the useWindowSize hook for that too. Here‘s an example of dynamically updating the font size of the header text:

function Header() {
  const { width } = useWindowSize();

  const styles = {
    fontSize: width > 500 ? ‘2rem‘ : ‘1.5rem‘,
  };

  return (
    <header>
      <div style={styles}>My App</div>
      {/* ... */}
    </header>
  );
}

In this version, we define a styles object with a fontSize property that changes based on the window width. We apply this object to the style prop of the <div> to dynamically update its font size as the window resizes.

This is a simple example, but you can imagine using this technique to build very complex and dynamic responsive layouts, all within the declarative world of React components!

Performance Considerations

While the useWindowSize hook is very handy, there are a couple of things to keep in mind regarding performance.

First, every time the window is resized, our event listener fires and triggers a state update in the hook. If you have many components using this hook, that can lead to a lot of unnecessary re-renders.

To mitigate this, you can debounce the updateSize function so it only fires after a certain period of inactivity:

useEffect(() => {
  if (!isSSR) {
    const debouncedUpdateSize = debounce(function updateSize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }, 1000);

    window.addEventListener(‘resize‘, debouncedUpdateSize);
    return () => window.removeEventListener(‘resize‘, debouncedUpdateSize);
  }
}, [isSSR]);

Now the state will only update at most once per second, even if the window is resized many times in that period.

Another optimization is to memoize the return value of the useWindowSize hook. Since the window size object will be a new reference every time the hook runs, using it as a dependency in other hooks or memo comparisons will always trigger updates.

To avoid this, you can wrap the returned size object in useMemo:

function useWindowSize() {
  const isSSR = typeof window === ‘undefined‘;
  const [size, setSize] = useState({
    width: isSSR ? 0 : window.innerWidth,
    height: isSSR ? 0 : window.innerHeight,
  });

  // ...

  return useMemo(() => size, [size]);
}

Now the hook will always return the same object reference unless the size state actually changes.

Going Further with Responsive Hooks

The useWindowSize hook we‘ve built is a great starting point, but there are more things you can do to make it even more useful.

For example, you could define a set of named breakpoints in a configuration object:

const breakpoints = {
  sm: 500,
  md: 800,
  lg: 1200,
};

Then update the hook to return a set of boolean flags indicating which breakpoints are active:

function useWindowSize() {
  // ...

  const width = isSSR ? 0 : window.innerWidth;
  const breakpointFlags = {
    sm: width >= breakpoints.sm,
    md: width >= breakpoints.md,
    lg: width >= breakpoints.lg,
  };

  return useMemo(() => ({ width, height, ...breakpointFlags }), [width, height]);
}

Now components can use the hook like this:

function MyComponent() {
  const { sm, md, lg } = useWindowSize();

  return (
    <div>
      {sm && <SmallView />}
      {md && <MediumView />}
      {lg && <LargeView />}
    </div>
  );
}

This makes the responsive logic even more declarative and readable.

You could also extend the hook to return a derived "responsive mode" string based on the active breakpoint:

function useWindowSize() {
  // ...

  let mode = ‘xs‘;
  if (lg) mode = ‘lg‘;
  else if (md) mode = ‘md‘;
  else if (sm) mode = ‘sm‘;

  return useMemo(() => ({ width, height, ...breakpointFlags, mode }), [width, height]);
}

Then use it like this:

function MyComponent() {
  const { mode } = useWindowSize();

  return (
    <div className={`my-component my-component--${mode}`}>
      {/* ... */}
    </div>
  );
}

This automatically applies a modifier class to the component based on the current responsive mode, which can be used to toggle different styles.

The sky‘s the limit with custom responsive hooks in React! By moving this logic out of your CSS and into your components, you gain a ton of flexibility and control over how your app adapts to different screen sizes.

Conclusion

Responsive design is a critical aspect of modern web development, and React provides some excellent tools for implementing it in a declarative and maintainable way.

By creating a custom useWindowSize hook, you can easily make your components responsive to the current viewport size without littering your code with imperative window measuring logic.

This hook can be used to conditionally render different components, update styles dynamically, or trigger different behaviors based on the window size. And with a few additional performance optimizations and enhancements, it can become an indispensable tool in your responsive design toolkit.

Give the useWindowSize hook a try in your next React project, and experience the benefits of truly responsive components! Your users will thank you.

Similar Posts