kyle halleman home

Safely rendering localized dates in Next.js

August 29, 2023

Recently I’ve been working on a project that involves displaying a lot of date and time information in the interface. The project uses Next.js and loads all of its data on the server, allowing the app to work reasonably well without JavaScript.

But I kept noticing errors in the browser console:

Error: Minified React error #418; visit https://reactjs.org/docs/error-decoder.html?invariant=418 for the full message

I never encountered these errors locally, only up in an environment. I didn’t know the source of the rendering mismatch because the error was minified. One day, my manager was reviewing a new merge request and asked how the browser’s Intl API worked where I was formatting some dates and times. Then it dawned on me what the hydration error was.

I never got these errors locally because my local server and my local browser’s timezones were the same, but up in an environment the server’s timezone was not the same as my machine’s. I learned how to set the timezone in Node and tested it out locally. Now I got the un-minified error:

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

Warning: Text content did not match. Server: "10:20 PM" Client: "3:20 PM"

See more info here: https://nextjs.org/docs/messages/react-hydration-error

I tried looking if there was any way I could get the user’s timezone sent to the server on the Request object. I had heard about the Client Hints API, but had never used it. Maybe that had what I needed? No.

After a lot of searching, I realized there was no viable solution to get the user’s timezone on the initial render so I could properly server render the formatted dates and times. Instead, I decided to not even attempt to format the dates, so the server and initial client render were the same and the hydration errors would disappear.

I set up a simple component for this:

function ClientText({ fallback = "", children }) {
	const [isMounted, setIsMounted] = useState(false);

	useEffect(() => {
		setIsMounted(true);

		return () => {
			setIsMounted(false);
		};
	}, []);

	if (isMounted) {
		return children;
	} else {
		return fallback;
	}
}

useEffect isn’t called on the server render and isMounted will be false on the first client render. Once the useEffect runs on that first client render, it sets isMounted to true, which triggers another render of the component. Now on the second render the user will see the formatted date and time for their timezone.

Can you guess what the problem is with this implementation?

The initial render will show an unformatted ISO string. It’s similar to the FOUC or FOUT. That’s not the worst, though. The way this application loads, you don’t see that initial server and client render because it’s a widget in a sidebar that loads when the page loads. And once the app loads, Next.js will client render page transitions.

The problem comes when loading parts of pages on the client. This is a chat app, and the user can scroll up and load older messages. At this point, that original ClientText component falls apart and there is a noticeable flash of unformatted dates (FOUD).

I didn’t have an answer. I scoured the Internet to no avail. I even tried ChatGPT (for my first time ever) and it utterly failed and just reworded the question I asked as an answer.

I moved on to other work. This sat in the back of my mind for weeks. One night when I went to bed, I told myself I would spend the time from when my head hit the pillow to whenever I fell asleep thinking about this problem.

And I thought up a solution.

When I woke up the next morning, I headed to my laptop and did the work. And it solved the FOUD problem.

The key was moving the isMounted check higher up the component tree and putting that value into context. With Next, I could put my isMounted logic at the very top of the page, and as each page transitioned or the user loaded older messages, the value of isMounted stayed the same. After that initial load of the application, it was true.

The modified ClientText looks like this:

function ClientText({ fallback = "", children }) {
	const isMounted = useMountContext();

	if (isMounted) {
		return children;
	} else {
		return fallback;
	}
}

The useState and useEffect logic was moved into its own custom hook, and a context at the top level implements it and provides the useMountContext hook.