Source code for this post can be found here
Not too long ago, I was implementing a carousel component that would render differently sized images for different breakpoints. I was using NextJS optimized images component and the image size was configured on the JavaScript side. On top of that, the number of images rendered in the view could be different across the breakpoints too (e.g. the desktop view has 5 columns and mobile has 2). The task sounds simple enough.
The problem
To put it simply, the server is not aware of the end user's device size. Therefore, we are forced to server-side render the website's content based on a specific viewport. It is rarely a problem and you even might have not encountered it yet. However, in my case, the server would SSR the carousel for the different theme (breakpoint) and then re-render it on the client-side using the correct theme. As a result, users on low-end devices could experience a content flash until the correct view is rendered. For example, the carousel is SSR'ed using the desktop breakpoint, so low-end mobile device users would see a flash of desktop content before the carousel is rerendered for the mobile on the client-side.
As a reminder, server-side content must match client-side content during hydration render, thus it is not possible to initially render the correct theme on the client-side. If server and client contents do not match, React issues a warning and your application might even break - depending on what caused the mismatch.
Measuring the problem's impact on user experience
It is quite easy to measure how much such content flash on application load impacts user experience. Open-source tool Lighthouse, which you can find in your Chromium-based browser's DevTools, collects statistics about Cumulative Layout Shift (CLS). According to web.dev docs:
Cumulative Layout Shift (CLS) is an important, user-centric metric for measuring visual stability because it helps quantify how often users experience unexpected layout shifts—a low CLS helps ensure that the page is delightful.
To measure CLS, visit your application in Incognito mode and open DevTools. Go to the Lighthouse tab in the DevTools. You will see something like this:
In this post, I will be using only the performance category of the Lighthouse report because it collects CLS statistics.
Note: if you are measuring an app that is running locally, keep in mind to measure a production build, because the development build includes features that will interfere with your results (e.g. Webpack dev server).
Carousel example
Source code for the initial carousel implementation before changes can be found here in the branch named before-changes
For this example, I am using:
- NextJS for React SSR
- SCSS for styling
- CSS Modules for modularizing SCSS styles
However, the final solution can be ported to other libraries/frameworks across the React stack.
Now, let's get familiar with the starting point - basic carousel implementation and theming support. For this example, I am using Material UI breakpoints that can be found here. I am defining different carousel configurations for different breakpoints.
- Desktop (xl)
- Mobile (sm)
Nothing too fancy. Notice, how the page title is changed for mobile view too. Let's look at the code.
Theme definition
Breakpoints are defined in the SCSS module and are later exported to JS.
theme.module.scss
$xs: 0;
$sm: 600;
$md: 960;
$lg: 1280;
$xl: 1920;
$breakpoints: ($xl, $lg, $md, $sm, $xs);
:export {
xs: $xs;
sm: $sm;
md: $md;
lg: $lg;
xl: $xl;
}
theme.js
import theme from './theme.module.scss';
const xs = Number(theme.xs);
const sm = Number(theme.sm);
const md = Number(theme.md);
const lg = Number(theme.lg);
const xl = Number(theme.xl);
export const breakPoints = [xl, lg, md, sm, xs];
export default {
xs,
sm,
md,
lg,
xl
};
Theme context
Theme context is used to conditionally render content based on the theme. It defaults to the XL theme for SSR and client-side hydration and later re-renders content using the correct theme. Furthermore, it listens for window resize events and updates the theme accordingly. getCurrentTheme returns the current window theme based on window size and existing breakpoints.
ThemeProvider.js
import { createContext, useContext, useEffect, useState } from 'react';
import theme from './theme';
import { getCurrentTheme } from './utils';
const defaultTheme = theme.xl;
const themeContext = createContext(defaultTheme);
export const useTheme = () => useContext(themeContext);
const ThemeProvider = ({ children }) => {
// Use XL theme for SSR and client-side hydration
const [currentTheme, setCurrentTheme] = useState(defaultTheme);
useEffect(() => {
// Initialize correct theme on the client side
setCurrentTheme(getCurrentTheme());
const handleResize = () => setCurrentTheme(getCurrentTheme());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return <themeContext.Provider value={currentTheme}>{children}</themeContext.Provider>;
};
export default ThemeProvider;
Application component
The application component wraps all application pages in the ThemeProvider.
_app.js
import { ThemeProvider } from '../theme';
import '../theme/global.scss';
const SsrDemoApp = ({ Component }) => (
<ThemeProvider>
<Component />
</ThemeProvider>
);
export default SsrDemoApp;
Index page component
There are 15 generated carousel items and the carousel configuration is defined in the getCarouselConfiguration function. useTheme hook is used to get the current theme and render content conditionally.
index.js
import { themes, useTheme } from '../theme';
import { Carousel } from '../components';
const carouselItems = Array.from({ length: 15 }, (_, index) => ({
title: index,
backgroundColor: '#616161'
}));
const getCarouselConfiguration = (theme) => {
switch (theme) {
case themes.xl:
return { itemWidth: 350, itemHeight: 500, itemsPerPage: 5 };
case themes.lg:
return { itemWidth: 250, itemHeight: 400, itemsPerPage: 5 };
case themes.md:
return { itemWidth: 200, itemHeight: 300, itemsPerPage: 4 };
case themes.sm:
return { itemWidth: 200, itemHeight: 300, itemsPerPage: 3 };
default:
return { itemWidth: 100, itemHeight: 200, itemsPerPage: 3 };
}
};
const ThemedCarousel = ({ items }) => {
const theme = useTheme();
return <Carousel items={items} {...getCarouselConfiguration(theme)} />;
};
const ThemedTitle = () => <h1>Simple carousel{useTheme() < themes.md ? ' (mobile view)' : ''}</h1>;
const Index = () => (
<div>
<ThemedTitle />
<ThemedCarousel items={carouselItems} />
</div>
);
export default Index;
Measuring existing implementation's CLS
As you may suspect, Lighthouse reports 0 CLS value for desktop applications. It does make sense - my screen size matches the XL theme, which is the one that is server-side rendered. So, no theme-change-caused client-side rerenders happen during application load.
However, the situation is different when using a mobile device. I get a CLS value of 0.558 (out of 1). According to web.dev docs:
What is a good CLS score? To provide a good user experience, sites should strive to have a CLS score of 0.1 or less.
So, the carousel component generates five times the recommended CLS value and greatly impacts mobile device user's experience.
The solution
Since the server is not aware of the end-user screen size, it must prepare multiple views of the application - for each breakpoint. However, this sounds very inefficient and can be improved:
- The server does not need to SSR the whole application using multiple breakpoints - this can be done to only specific VDOM branches (e.g. the carousel component).
- Sometimes it is not necessary to SSR all the breakpoints. For example, in the demo application, the page title can be rendered in two ways: 'Simple carousel' for MD, LG, and XL themes and 'Simple carousel (mobile view)' for other themes. Thus, it is only necessary to server-side render XL and SM themes here, because the XS theme will be covered by the SM whereas MD and LG - by the XL theme.
const ThemedTitle = () => <h1>Simple carousel{useTheme() < themes.md ? ' (mobile view)' : ''}</h1>;
After components with multiple themes are composed on the server and are sent to the client, the latter is responsible for picking the right theme to mount. It is important that the client mounts only one theme of the component. This must be ensured because the component can have side effects, like HTTP calls, so mounting two instances of the component will result in two HTTP calls being made which is not ideal.
There are two phases of client-side application load during which the app should pick the correct theme for the component: when stylesheets load and when JavaScript loads.
During the first phase, CSS media queries are used to hide invalid themes until React is loaded and the hydration render is applied. Then, invalid theme component instances can be omitted from the VDOM. This action will issue a hydration warning, however, it is safe to ignore it because React will cut off some branches from the VDOM completely, thus not making any negative impact on your application. More information on hydration can be found in React docs here.
The implementation
Full source code can be found here
1. Getting environment and render information
Next does not provide any information about the environment (client or server) in which the render is happening. However, it is quite easy to check for the environment - the client will have a global window object.
environment.js
const isServer = typeof window === 'undefined';
export default {
isServer,
isClient: !isServer,
};
A more tricky part is to check whether the current render is a hydration render. At first, let's set the flag to true because the first render is hydration.
environment.js
const isServer = typeof window === 'undefined';
export default {
isServer,
isClient: !isServer,
isHydrationRender: true,
};
This flag needs to be set to false after the whole app mounts - inside useEffect hook of the root component.
_app.js
import environment from '../core/environment';
...
useEffect(() => {
environment.isHydrationRender = false;
}, []);
...
2. Preparing theme boundaries using Media Queries
I am using SCSS to generate class names for hiding themes that do not match client's viewport. Recall that breakpoints are defined in an array sorted descendingly.
$xs: 0;
$sm: 600;
$md: 960;
$lg: 1280;
$xl: 1920;
$breakpoints: ($xl, $lg, $md, $sm, $xs);
Lower theme boundary will hide content below and including the specific theme. Upper theme boundary will hide content above the specific theme. For example, sm theme's boundaries are defined as such:
.sm-lower-boundary {
@media screen and (max-width: 959px) {
display: none;
}
}
.sm-upper-boundary {
@media screen and (min-width: 960px) {
display: none;
}
}
sm theme is rendered for viewport widths between 600 and 959 pixels (since md theme starts at 960px). So, lower boundary hides content when screen size is <= 959 pixels, where as upper boundary hides content when screen size is >= 960 pixels.
Boundaries for xl theme are not generated in this implementation because this theme is rendered for all screens widths starting from 1920px.
The code for generating boundaries is straightforward:
ssr-additional-themes.module.scss
@use 'sass:list';
@use '../theme.module' as themes;
$boundary-themes-map: (
'xs': themes.$xs,
'sm': themes.$sm,
'md': themes.$md,
'lg': themes.$lg,
);
@each $theme-name, $breakpoint in $boundary-themes-map {
$bigger-breakpoint-index: list.index(themes.$breakpoints, $breakpoint) - 1;
$bigger-breakpoint: list.nth(themes.$breakpoints, $bigger-breakpoint-index) * 1px;
.#{$theme-name}-lower-boundary {
@media screen and (max-width: $bigger-breakpoint - 1px) {
display: none;
}
}
.#{$theme-name}-upper-boundary {
@media screen and (min-width: $bigger-breakpoint) {
display: none;
}
}
}
3. Creating a wrapper component
Wrapper component is responsible for setting boundary classes and controlling which component's theme will remain in the VDOM after hydration renders. An example of the component API:
<SsrAdditionalThemes themes={[themes.sm]}>
<ThemedTitle />
</SsrAdditionalThemes>
Boundaries class names are set during hydration render (server and first client render), so this behaviour is controlled by the state. Variable (ssrThemes), containing an array of themes to SSR is computed once and it does not depend on props in subsequent re-renders.
const SsrAdditionalThemes = ({ themes: additionalSsrThemes = [], children }) => {
const [enableBoundaries, setEnableBoundaries] = useState(environment.isHydrationRender);
const [ssrThemes] = useState(() => Array.from(new Set([...additionalSsrThemes, themes.xl])).sort((x, y) => x - y));
...
};
Next, the component has to know which theme is mounted on the client and find it in the themes array, defined previously. If the exact theme cannot be found, the component fallbacks to a theme from ssrThemes array which breakpoint is bigger. This logic needs to be executed during the client's hydration render to omit unnecessary component themes from the VDOM and prevent them from mounting.
...
const initialMatchedClientThemeRef = useRef(null);
if (environment.isClient && !initialMatchedClientThemeRef.current) {
const currentTheme = getCurrentTheme();
initialMatchedClientThemeRef.current = ssrThemes.find((theme) => theme >= currentTheme);
}
...
Lastly, the component loops through selected themes to apply rendering logic and boundary class names. All selected themes are rendered on the server, whereas only the matched theme is rendered on the client. suppressHydrationWarning property is necessary to prevent warnings when the VDOM tree branch is omitted during hydration render.
cx function is used to concatenate class names, see classnames package.
themeNameMapper is a hashmap that stores theme names keyed by breakpoints.
...
return (
<div>
{ssrThemes.map((theme, themeIndex) => {
const canRenderTheme = environment.isServer || theme === initialMatchedClientThemeRef.current;
if (!enableBoundaries && !canRenderTheme) {
return null;
}
const boundariesClassNames =
enableBoundaries &&
cx(
themeIndex !== 0 && styles[`${themeNameMapper[ssrThemes[themeIndex - 1]]}LowerBoundary`],
styles[`${themeNameMapper[theme]}UpperBoundary`]
);
return (
<div
key={theme}
className={cx(styles.themeWrapper, boundariesClassNames)}
suppressHydrationWarning={!canRenderTheme}
>
{canRenderTheme && <ThemeProvider initialTheme={theme}>{children}</ThemeProvider>}
</div>
);
})}
</div>
);
After the component mounts, boundary class names are disabled and empty div tags are removed.
useEffect(() => setEnableBoundaries(false), []);
Testing results
Desktop results remain the same, scoring 0 points in CLS.
However, mobile results show that CLS was reduced from 0.558 (out of 1) to 0 and that the overall page performance has increased from 95 to 99.
Conclusion
This approach solves CLS problem in server-side rendered applications if the feature cannot be/is hard to implement using only CSS. However, it has some cons:
- themes property on SsrAdditionalThemes component needs to be maintained.
- HTML size increases because markup from other themes is included.
- Potential impact on SEO due to duplicate content.
- The browser might start loading images that are not visible in current breakpoint, but are rendered in others. You can use components like NextJS optimized image component to solve this problem.
Yet, used wisely, it can be a good tool in optimising page's CLS and delivering a better user experience for mobile users.
Also, take a look at a library @artsy/fresnel, which uses a similar approach to solve CLS issues in server-side rendered applications.
Top comments (0)