3

I have been switching my React + material-ui SPA to a Next.js statically rendered site (with next export). I have followed the steps shown on the material-ui example with next.js and everything works fine on non-mobile screen widths (> 960), but the content is shown unstyled in the initial render if the screen width on initial render is at or below the mobile breakpoint. Subsequently navigating to any page on the client renders pages correctly, even when navigating back to the original offending page which was broken on initial render, again this is only on mobile screen widths.

In my code there is a lot of this:

...
const windowWidth = useWindowWidth();
const isMobile = windowWidth < 960;
return (
    // markup
    { isMobile ? (...) : (...) }
    // more markup
);
...

Where useWindowWidth.js does this:

function useWindowWidth() {
  const isClient = typeof window === "object";
  const [width, setWidth] = useState(isClient ? window.innerWidth : 1000); // this will be different between server and client
  useEffect(() => {
    setWidth(window.innerWidth);
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);
  return width;
}

Any page that has this will show this warning in the console when the initial render is done within the bounds of a mobile screen width:

Warning: Expected server HTML to contain a matching <div> in <div> // or something similar depending on what was conditionally rendered with isMobile

Only these pages have this css styling issue. It seems that when rendering these pages within that screenwidth when there is conditional rendering creates styles with a different name, so instead of the makeStyles-button-96 class the element calls for it will only have makeStyles-button-97 therefore leaving the element unstyled.

I have been through the material-ui issues and the docs, and made sure my project reasonably mirrors the examples. Including the _document.js and _app.js files. How do I remedy this?

PS:

There was something I recall reading on my search which stated that React expects server and client rendered output to match but if there is no way around it there is some way to signify this in the code. I am not sure if this only silences the warning or if it prevents the class renaming altogether. Can someone please shed some light on this? I can't seem to find where I read that...

Problem Identified:

To be clear, the window width difference between the server and client, is the offender here. In the useWindowWidth hook shown above, setting the default to below the 960 mobile threshold, like this:

const isClient = typeof window === "object";
const [width, setWidth] = useState(isClient ? window.innerWidth : 900); // change the default to 900 if not on client, so below the mobile threshold

Makes the inverse of my problem happen. So initial load on a mobile screenwidth is fine but a larger screen width breaks the css with mismatched class names. Is there a recommended method to conditionally render depending on screen width that would somehow keep the output the same?

UPDATE:

While I have found a fix, as stated in my own answer below, I am not satisfied with it and would like to better understand what is happening here so I can address this at build time as opposed to this solution which patches the issue as opposed to preventing it. At this point any answer which just points me in the right direction will be accepted.

Verbal_Kint
  • 1,366
  • 3
  • 19
  • 35
  • 1
    Have you looked at this related question: https://stackoverflow.com/questions/61510890/how-to-implement-ssr-for-material-uis-media-queries-in-nextjs? – Ryan Cogswell Jun 22 '20 at 14:49
  • 1
    And the related documentation referenced in that question: https://material-ui.com/components/use-media-query/#server-side-rendering. – Ryan Cogswell Jun 22 '20 at 14:50
  • @RyanCogswell I may be missing something, but I don't see the overlap here with the exception of the common libraries used for both questions. Correct me if I'm wrong but he wants to implement MUI's media query to conditionally render components whereas I've implemented my own hook to do so but the difference in components rendered seem to cause a branching of css class names... – Verbal_Kint Jun 26 '20 at 19:23
  • @RyanCogswell also, please post any possible solution as an answer so I may award you the points should the solution be relevant – Verbal_Kint Jun 26 '20 at 19:30
  • 1
    The `useMediaQuery` approach outlined in that question and the documentation provides a way to make a better guess at the server-side default based on user-agent. I'm saying you should use that approach instead of your own `useWindowWidth`. – Ryan Cogswell Jun 26 '20 at 19:35
  • @RyanCogswell I see, so this would address the issue by adjusting the default window width on the server with a better guess as to the client's window width therefore avoiding the different renders to begin with. The issue is, as I mentioned in the beginning of the question, this site is statically rendered with `next export`. I probably should've been more clear about that seeing as I only mentioned that in the very beginning of a rather lengthy question. – Verbal_Kint Jun 26 '20 at 19:40
  • 1
    `useMediaQuery` is written with SSR in mind and makes it easier to ensure that the initial client rendering is in sync with the server side. If the guess at the window width is incorrect it will be changed in an effect rather than initial rendering. Your implementation is looking at `window.innerWidth` during the initial rendering and thus is problematic for SSR. – Ryan Cogswell Jun 26 '20 at 19:42
  • @RyanCogswell In summary, this site is statically rendered and served from an S3 bucket so I wouldn't think the "server" ever really gets a chance to make those adjustments. Technically the SSR happens once on build in my case, so the media query default guess only happens once on the build machine and not on every request. – Verbal_Kint Jun 26 '20 at 19:42
  • 1
    The difference in timing (effect vs. initial render) of how `useMediaQuery` adjusts when the server-side guess is incorrect will still be important (even if in your case, the server-side guess won't actually have a meaningful user-agent to base the guess on). – Ryan Cogswell Jun 26 '20 at 19:45
  • @RyanCogswell From what I've read so far and our comments I'd think the approach I've settled on may be the best way. Either way if you'd be so kind as to post the summary of our comments as an answer I'd be inclined to choose your answer seeing as it is by far the most relevant – Verbal_Kint Jun 26 '20 at 19:47
  • @RyanCogswell I see, I'll give it a try and will post back here. Either way, if you find the time to do so, please post your comments as an answer. – Verbal_Kint Jun 26 '20 at 19:49

4 Answers4

1

You can use next/dynamic with { ssr: false } as described here. Basically, isolate the relevant code for this issue into its own component, and then dynamically import it with ssr turned off. This avoids loading the specific code that requires window server-side.

You can also use a custom loading component while the component is dynamically fetched as described here.

or even just provide an explanation on Next's build time mechanics where styling is addressed, that would be greatly appreciated.

The problem basically boils down to there not being a window object present during ssr is my basic understanding. I had a similar issue to you with a Bootstrap carousel I was working with, and I think the dynamic import is what I'll be going with - this solution allows us to not modify our code at all for the sake of ssr, aside from simply isolating the relevant code.

1

From the ReactDOM.hydrate documentation:

React expects that the rendered content is identical between the server and the client.

But by leveraging window.innerWidth during your initial render in the following:

const [width, setWidth] = useState(isClient ? window.innerWidth : 1000);

you are causing the initial client rendering to be different than the server whenever the width causes different rendering than what is caused by a width of 1000 (e.g. such as when it is less than 960 in your code example that branches on isMobile). This can cause various hydration issues depending on what kind of differences your width-based branching causes.

I think you should be able to fix this by just simplifying the useState initialization to hard-code 1000:

function useWindowWidth() {
  const isClient = typeof window === "object";
  const [width, setWidth] = useState(1000);
  useEffect(() => {
    setWidth(window.innerWidth);
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);
  return width;
}

The effect is already calling setWidth(window.innerWidth) unconditionally which should take care of updating the layout after initial rendering if needed (e.g. for mobile).

If you aren't ever using the specific width and are only using it as a threshold for branching, I would recommend using Material-UI's useMediaQuery instead of your custom useWindowWidth hook. The documentation then explains ways of dealing with server-side rendering reliably. In your case (using next export), you could use a simpler server-side ssrMatchMedia implementation that always assumes 1024px rather than including user-agent parsing to try to detect device type.

In addition to taking care of SSR issues, useMediaQuery will trigger less re-rendering on width changes since it will only trigger a render when the window size crosses the threshold of whether or not the media query specified matches.

Related answer: How to implement SSR for Material UI's media queries in NextJs?

Ryan Cogswell
  • 75,046
  • 9
  • 218
  • 198
0

For anyone with a similar problem, here is the fix I found. I have to say though, it feels like a hack instead of addressing the root problem.

Material UI: Styles flicker in and disappear

Basically, the poster (@R R) is fixing the problem after the fact by forcing a refresh/re-render on the client with an on mount effect by changing the key prop in an element in his _app.js file.

While this does fix the styling I would think a cleaner solution would address the problem at build time. If anyone has any idea how to address this at build time or can at least shed some light on where to look for the issue, or even just provide an explanation on Next's build time mechanics where styling is addressed, that would be greatly appreciated.

My guess is that the difference in what is rendered between mobile and other screen widths through the conditional rendering outlined in the question causes some kind of branching of class names. At least that's what the warning logged in the console would lead me to believe. I still can't find that article I had mentioned in my question discussing that warning and a way to address it (whether just silencing the warning, or more importantly, preventing the mismatched class names altogether). If anyone has a link to that article/blog/site, I would greatly appreciate it.

Verbal_Kint
  • 1,366
  • 3
  • 19
  • 35
0

you can try useMediaQuery hook from material ui, this will give the width of the window and do the update if its change. If you need custom breakpoints also you can update in theme

import withWidth from '@material-ui/core/withWidth';

function MyComponent({width}) {
  
 const isMobile = (width === 'xs');
 return (
    // markup
   { isMobile ? (...) : (...) }
   // more markup
   );

export default withWidth()(MyComponent);

For custom breakpoint you can try like this

const theme = createMuiTheme({
  breakpoints: {
    values: {
     mobile: 540,
     tablet: 768,
     desktop: 1024,
    },
  },
})
Raj Kumar
  • 839
  • 8
  • 13
  • I'm not sure how this addresses the question. Are you saying that implementing MUI's screen width hook as opposed to my implementation to conditionally render components will have a different outcome? – Verbal_Kint Jun 26 '20 at 19:26