Jacob Paris
← Back to all content

Avoid hydration errors with CSS and progressive enhancement

For all the benefits of serverside rendering, there are also things it makes more difficult, like showing the same date in the client and the server.

Any time there's a mismatch between what the server can render and what the client can render, you're going to run into hydration issues and your site might bail on serverside rendering entirely after a flash of unstyled/incorrect content.

Some people try to solve this by only rendering certain elements on the client. That's a sure way to avoid the mismatch, but it also means that users without javascript will never see that content.

There's no way of knowing whether javascript is eventually going to load or not

  • it might just be a slow connection
  • the connection could have failed or been blocked
  • the user might have disabled javascript

Users without javascript will get whatever version of the page is rendered on the server, so if you want progressive enhancement, the server MUST render the content.

Javascript solutions don't work here. Even if you prevent the hydration error when rendering a date, there will still be a flash of the wrong date before React is even downloaded.

A CSS Solution

So if javascript solutions are out of the question, what can we do?

  1. We can server-render the content with a CSS animation that makes it invisible for 3 seconds or so, then appear suddenly.
  2. If Javascript loads at any point, we remove the animation class and the client-rendered content appears immediately, or with a nice fade-in.

I've built a custom ProgressiveClientOnly component that does this for you.

A date that's different between server and client

Dates are a common source of mismatch between server and client. Probably the most common. If you're getting hydration errors, and it's not a browser extension, it's probably a date.

There is a whole world of complexity here if you want accurate dates across timezones, but for the sake of this example, let's assume you're just trying to show the user a date and you don't want it to visibly flash different values.

Here we're assuming you have a date in your loader data that you want to display to the user. To help illustrate the conflict between server and client dates, we'll hardcode the server date to the first of January 2023 and use the client date as the current date.

tsx
<label className="flex flex-col">
Regular
<input type="text" defaultValue={date} />
</label>

The zero JS case is fine here, since it just shows the server date, but most users are going to get the example on the right with the flash of the wrong date.

You can use the ProgressiveClientOnly component to display only the client-rendered date but fall back to the server-rendered date if the user has JS disabled.

jsx
<ProgressiveClientOnly className="animate-fade">
<label className="flex flex-col">
Progressive with JS
<input type="text" defaultValue={date} />
</label>
</ProgressiveClientOnly>

Now if you refresh the page, you'll see each input only shows one date and there's no incorrect content.

As a tradeoff, the content can't appear on first load, but you can usually hide that with a nice fade-in animation.

An input with a default value from local storage

Imagine you have a long form and you want to save the user's progress as they fill it out. Maybe the data is sensitive, or you want it to work offline, so you save to local storage while they're working on it.

The server doesn't have access to local storage, so at the moment the page loads, that input is going to be empty. You can immediately populate it with the value from local storage, but you're going to get the empty input for a split second.

jsx
<label className="flex flex-col">
Regular
<input type="text" defaultValue={localStorageValue} />
</label>

If javascript never loads, then the user will never see the value from local storage, but that's not a huge deal. They'll just have to fill out the form from scratch.

The bigger issue is that most users will see the empty input for a split second before the value from local storage is populated.

The progressive solution hides the flash by fading in the surrounding content at the same time as the input, so it's less jarring.

jsx
<ProgressiveClientOnly className="animate-fade">
<label className="flex flex-col">
Progressive with JS
<input type="text" defaultValue={localStorageValue} />
</label>
</ProgressiveClientOnly>

A custom file input with a native fallback

If you want your users to get a nice file drop zone that shows image thumbnails and lets them drag and drop files, the only way JS-free users will still be able to use the app is if you show them a native file input.

We can try to use the Remix Utils ClientOnly component to render the fancy file input only on the client, but that's going to cause a flash of the native input before the client loads.

jsx
<ClientOnly fallback={<input type="file" />}>
{() => <FancyFileDropper />}
</ClientOnly>

We can use the ProgressiveClientOnly component for this too.

  • The file input starts its reveal animation as hidden, and when javascript loads, we make it permanently hidden by giving it the sr-only class.
  • For the fancy picker, we want the reverse behavior. We start it as visible with defaultShow={true}, and then if JS loads and the animation is removed, we just leave it. If JS doesn't load, the animation will hide it.
jsx
<ProgressiveClientOnly defaultShow={true}>
<FancyFileDropper />
</ProgressiveClientOnly>
<ProgressiveClientOnly className="sr-only">
<input type="file" />
</ProgressiveClientOnly>

This example shows the effect much more prominently. The most common case of JS loading looks terrible without the progressive enhancement. On the other hand, the zero JS case, which are less common, are still usable here.

Many devs would avoid the flash by dropping the native input entirely and break the app for all users without javascript.

Code

tsx
import { useHydrated } from "remix-utils/use-hydrated"
export function ProgressiveClientOnly({
children,
className = "",
defaultShow = false,
}: {
children: React.ReactNode | (() => React.ReactNode)
className?: string
defaultShow?: boolean
}) {
const isHydrated = useHydrated()
return (
<div
className={
isHydrated
? className
: defaultShow
? // Create these animations in CSS or your tailwind config
"[animation:disappear_1000ms]"
: "[animation:appear_1000ms]"
}
>
{typeof children === "function"
? children()
: children}
</div>
)
}

The custom CSS animations for appearing/disappearing can be done in regular CSS or in your tailwind.config.ts.

tsx
export default {
theme: {
extend: {
keyframes: {
appear: {
"0%, 99%": {
height: "0",
width: "0",
opacity: "0",
},
"100%": {
height: "auto",
width: "auto",
opacity: "1",
},
disappear: {
"0%, 99%": {
height: "auto",
width: "auto",
opacity: "1",
},
"100%": {
height: "0",
width: "0",
opacity: "0",
},
},
},
},
},
},
}
Professional headshot
Moulton
Moulton

Hey there! I'm a developer, designer, and digital nomad building cool things with Remix, and I'm also writing Moulton, the Remix Community Newsletter

About once per month, I send an email with:

  • New guides and tutorials
  • Upcoming talks, meetups, and events
  • Cool new libraries and packages
  • What's new in the latest versions of Remix

Stay up to date with everything in the Remix community by entering your email below.

Unsubscribe at any time.