Hiding a sidebar on mobile without a hydration mismatch
Hiding a sidebar on mobile without a hydration mismatch

Watch everything for $29/month.

Join Build UI Pro

Hiding a sidebar on mobile without a hydration mismatch

Use the screen width to seed some React state in a way that's robust to server-side rendering.

Video resources

Summary

We're starting off with a layout that has a sidebar. The sidebar can be toggled open or closed, and it has a different styling treatment on desktop and mobile.

The sidebar currently works, but it would be nice if we could default it to open on desktop and closed on mobile, since there's plenty of room for it on large screens.

Our first attempt may look something like this:

let [open, setOpen] = useState(window.innerWidth >= 1024);

This is giving our open state a default value of true when the window is 1024 pixels or larger, and false otherwise.

If we save this code and refresh our app, we'll see an error:

Error: window is not defined

This is because we're running a Next.js app which supports server-side rendering; but even if you're not working with Next, most React apps these days run on the server, and will therefore hit this same error when rendering on the server.

So, we can't use window on the server. Let's add a guard to prevent the error:

let [open, setOpen] = useState(
  typeof window !== "undefined" && window.innerWidth >= 1024
);

If we look at the app on mobile, we'll see everything works. But if we refresh on desktop we'll see a new error:

Error: Hydration failed because the initial UI does not match what was rendered on the server.

If you've been using React long enough you've likely encountered this error before. It actually doesn't break our app in production, and in fact if we dismiss it, our app is still functional. But the error does mean there's a problem in our code somewhere and almost always indicates an actual bug that's visible to our users.

In our case, if we add a debugger to our page, we'll see that the desktop version of our site flickers: the initial render from the server has the sidebar closed (since window is always undefined there), whereas the initial render on the client has the sidebar open (since window is defined and the width is greater than 1024).

So, guarding against window being undefined is not enough. We simply cannot use window for the initial render of our app, since it's not available on the server.

The big takeaway here is this: we must only rely on APIs that exist in both the server and the client for our initial render, so that the first render on the server and the first render on the client always match. This prevents bugs related to hydration, such as flickering elements on initial render, from making their way into our applications.

So – what can we do instead?

Hiding a non-interactive sidebar with CSS

If we comment out our JavaScript for a second and just look at our template, we'll see that we are already using reponsive CSS to adjust the styling of our sidebar on mobile and desktop screens.

And if we think about it, this CSS is already robust to server side rendering. We can refresh our page on mobile or desktop without any flickering, because both the server and client can output CSS rules that apply as soon as our app is rendered.

So – can we use CSS to hide the sidebar on desktop? Sure we can!

- <div className="fixed inset-y-0 right-0 flex lg:sticky lg:h-screen">
+ <div className="fixed inset-y-0 right-0 hidden lg:flex lg:sticky lg:h-screen">
    {/* ... */}
  </div>

Here we're using Tailwind, but any approach to CSS media queries would work. The hidden class applies the display: none CSS rule, and lg:flex overrides display to be flex on screens that are 1024 pixels and up.

If we take a look, our approach works, and it's robust to SSR! If we refresh on large screens, the sidebar is there, and if we refresh on small screens, it's hidden. All with no flickering.

But of course, we need React if we want to be able to actually toggle the sidebar. So, what happens if bring back the JavaScript that we commented out?

If we uncomment our JavaScript code and save, we'll see that it works on desktop, but it doesn't work on mobile. And that's because even though our sidebar is being rendered by React, it's being hidden with CSS:

<div>
  {/* React is rendering the sidebar if `open` is true... */}
  {open && (
    {/* ...but CSS is hiding it regardless via `hidden`. */}
    <div className="fixed inset-y-0 right-0 hidden lg:flex lg:sticky lg:h-screen">
      {/* ... */}
    </div>
  )}
</div>

React and CSS are competing with each other for who gets to determine whether the sidebar is rendered or not.

A single source of truth

The fundamental problem here is that we don't have a single source of truth for our sidebar's open state.

Let's think about what the source of truth should be during each render of our application:

  • On the first render, CSS needs to be the source of truth, since it's robust to SSR.
  • On the second render and on, once React has hydrated, our React state needs to become the source of truth, since our sidebar is now interactive and can be toggled via the button.

In other words, we need to start out with CSS as the source of truth for our sidebar's visibility, and then move that value into our React state.

But how can we do this?

Seeding React state from the DOM

One way is to create a third state for our open variable. This state should tell us that open is indeterminate, and it's actually not the source of truth yet.

Let's use undefined to represent this state:

let [open, setOpen] = useState<boolean>();

Now our open variable starts out as undefined. And when we're in this state, we want our sidebar to always render from the perspective of React; we don't want React to interfere, because the source of truth is CSS.

So, we'll always render our sidebar if open is undefined:

<div>
  {open === undefined && (
    <div className="fixed inset-y-0 right-0 hidden lg:sticky lg:flex lg:h-screen">
      {/* ... */}
    </div>
  )}
</div>

This makes our app work on initial render for both mobile and desktop.

So how do we take care of the second render? How do we move the source of truth from CSS to React?

An Effect sounds like a perfect way to solve this.

Effects run after a component has finished rendering, so we don't have to worry about them modifying our initial render, either on the server or the client.

This also means that in the body of an Effect, we know we're on the client, so we can safely access client-only APIs like window.innerWidth.

So, we can use an Effect to seed the initial value of our open state, and therefore move it from undefined to either true or false:

useEffect(() => {
  let initialOpen = window.innerWidth >= 1024;

  if (open === undefined) {
    setOpen(initialOpen);
  }
}, [open]);

If we drop a console.log in and see how the value of open changes, we'll see two values: undefined and then true if we're on desktop screens, or undefined and then false if we're on mobile.

We've successfully updated our state based on the window size starting in the second render!

Using React as the source of truth after the first render

All that's left to do is to use our open state as the single source truth for whether the sidebar is shown or hidden.

First, we want to render the sidebar if open is either undefined or true:

<div>
  {(open === undefined || open === true) && (
    <div className="fixed inset-y-0 right-0 hidden lg:sticky lg:flex lg:h-screen">
      {/* ... */}
    </div>
  )}
</div>

If we check the inital render, it's correct on both screen sizes.

Now let's try our button. If we go to desktop, everything works, but on mobile, the sidebar is always hidden. That's because our hidden CSS class is still taking effect.

So, we want to remove that class after the initial render, so that open is the only source of truth. We can do that by using an expression and only apply the class when open is undefined:

<div>
  {(open === undefined || open === true) && (
    <div
      className={`${
        open === undefined ? "hidden lg:flex" : "flex"
      } fixed inset-y-0 right-0 lg:sticky lg:h-screen`}
    >
      {/* ... */}
    </div>
  )}
</div>

Now our CSS class is no longer being applied after the first render, open has become the single source of the truth, and our button works!

We've solved the problem by moving the correct value of open from the DOM to React.

Refactoring our code

Before we wrap up, let's clean up our code a little bit.

First, our JSX:

<div>
  {(open === undefined || open === true) && (
    <div
      className={`${
        open === undefined ? "hidden lg:flex" : "flex"
      } fixed inset-y-0 right-0 lg:sticky lg:h-screen`}
    >
      {/* ... */}
    </div>
  )}
</div>

Imagine opening this component and seeing these open === undefined expressions. They're a bit out of place!

Let's create a new named variable at the top of our component to add some clarity:

let isInitialRender = open === undefined;

and replace the expressions with this variable:

<div>
  {(isInitialRender || open === true) && (
    <div
      className={`${
        isInitialRender ? "hidden lg:flex" : "flex"
      } fixed inset-y-0 right-0 lg:sticky lg:h-screen`}
    >
      {/* ... */}
    </div>
  )}
</div>

That communicates our intent a bit better.

Next, if we look at our conditional className, it's kind of a bummer that we had to move our flex styling inside of it:

<div
  className={`${
    isInitialRender ? "hidden lg:flex" : "flex"
  } fixed inset-y-0 right-0 lg:sticky lg:h-screen`}
>
  {/* ... */}
</div>

The conditional is there to hide the sidebar on mobile, but now it also contains specific knowledge about how the sidebar is styled. The rest of the sidebar's styling classes are in the static list, so this makes the whole thing harder to change.

We have to do this because by default, Tailwind is mobile-first, meaning its classes apply starting from the smallest viewport and up. We override breakpoints once the screen reaches a certain width. In general, even outside of Tailwind, this is the best approach for styling a web app. But in this case, we're only applying the hidden class because we want to hide the sidebar on screens that are 1024 pixels or less. In other words, ideally we'd be able to apply the hidden class using a max-width breakpoint.

It turns out CSS supports max-width media queries, and so does Tailwind! If we use max-lg:hidden, we can move the flex class back to our static class list:

<div
  className={`${
    isInitialRender ? "max-lg:hidden" : ""
  } fixed inset-y-0 right-0 lg:sticky lg:h-screen`}
>
  {/* ... */}
</div>

Now the conditional is only concerned with hiding the sidebar on mobile. In fact, we can even create a new div to make this even more clear and composable:

<div className={`${isInitialRender ? "max-lg:hidden" : ""}`}>
  <div className="fixed inset-y-0 right-0 lg:sticky lg:h-screen">
    {/* ... */}
  </div>
</div>

Much better!

Finally, we've introduced some coupling between the div that hides our sidebar and the pixel value in our Effect:

function Component() {
  useEffect(() => {
    // This 1024...
    let initialOpen = window.innerWidth >= 1024;

    if (open === undefined) {
      setOpen(initialOpen);
    }
  }, [open]);

  return (
    // ...must match the max-lg media query here.
    <div className={`${isInitialRender ? "max-lg:hidden" : ""}`}>
      <div className="fixed inset-y-0 right-0 lg:sticky lg:h-screen">
        {/* ... */}
      </div>
    </div>
  );
}

If we wanted to change our page so that the sidebar is initially visible on tablet, we'd have to change both max-lg:hidden to max-md:hidden, and also change 1024 to 768.

So, 1024 is something of a magic number.

If we think about it, we're actually using the screen width in our Effect as a proxy for whether or not the sidebar is visible. There is another client-side API we can use to detect whether the sidebar is actually visible: window.getComputedStyle(el). This gives us the actual computed values for the element as it is rendered by the browser. If we inspect the display property, we'll see it shows up as none on mobile and block on desktop:

// This will be "none" on mobile, and "block" on desktop
window.getComputedStyle(sidebarElement).display;

So, if we use this API instead, we can read the display property of the sidebar directly, and use that to seed our React state:

function Component() {
  let sidebarRef = useRef(null);

  useEffect(() => {
    if (isInitialRender && sidebarRef.current) {
      let initialOpen =
        window.getComputedStyle(sidebarRef.current).display !== "none";

      setOpen(initialOpen);
    }
  }, [open]);

  return (
    <div
      ref={sidebarRef}
      className={`${isInitialRender ? "max-lg:hidden" : ""}`}
    >
      <div className="fixed inset-y-0 right-0 lg:sticky lg:h-screen">
        {/* ... */}
      </div>
    </div>
  );
}

Now we can change max-lg:hidden to max-md:hidden, show our sidebar on the initial render on tablet screens, and not have to change any of the code in our Effect.

And with that, we've successfully hidden our sidebar on mobile in a way that's robust to server-side rendering!

Join Build UI Pro to access this video's summary and source code.

Join Build UI Pro

Watch every video, support our work, and get exclusive perks!

Build UI is the new home for all our ideas. It will eventually have hundreds of premium videos and a thriving community, but right now it's the early days.

If you like what you see and you've ever wanted to support our work, subscribe today and start enjoying all the perks of becoming a member!

$29/month

Watch everything. Cancel anytime.

What you'll get as a Build UI Pro member

Full access to all Build UI videos

Get full access to all of our premium video content, updated monthly.

Private Discord

Ask questions and get answers from Sam, Ryan and other pro members.

Video summaries with code snippets

Easily reference videos with text summaries and copyable code snippets.

Source code

View the source code for every video, right on GitHub.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!