Displaying Local Times in Next.js
Time is an illusion. Lunchtime doubly so.
Douglas Adams - H2G2
You may be familiar with React hydration issues when using Next.js, or other server-side rendered frameworks:
This happens when the server renders something, but the client re-renders it differently.
Now imagine you want to display a specific point in time in your app. This could be the publication date/time of a comment for example. Your source of data will likely be a timestamp, or be a Date object rooted in UTC.
Rendering it statically as-is will be impractical for most of your users. UTC is great for machines, but humans prefer to see times in their local timezone.
For example, this post was published on:
- 27 September 2023, at 10:00 in my local time (Europe/Paris)
2023-09-27T08:00:00Z
in UTC
We’re going to build a React component that deals with that, that works with Next.js 13.4+ and React 18, in a mix of server and client components.
The local timezone will only be available when rendering on the client, so even if
this is not technically “interactive” content, we’ll need to use the
use client
directive to allow re-rendering a component on the client.
useHydration
A trick often used to avoid hydration issues is to render the same content on the server and on the hydration pass, but then trigger a re-render to update the content when the client has the information we need.
export function useHydration() {
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
return hydrated
}
We can use this signal to render a default value on the server (eg: the UTC time), and to re-render the component on the client when the local timezone is available.
'use client'
import { useHydration } from 'hooks/useHydration'
export function LocalTime({ date }: { date: Date | string | number }) {
const hydrated = useHydration()
return (
<time dateTime={new Date(date).toISOString()}>
{new Date(date).toLocaleTimeString()}
{hydrated ? '' : ' (UTC)'}
</time>
)
}
Suspense
Simply re-rendering the component is not enough though. In Next.js and with React 18, this will still cause a hydration mismatch.
We could silence it by slapping a suppressHydrationWarning
prop on the component containing our timestamp, but it’s only hiding the problem,
and the resulting rendered time would still be stuck in UTC.
The hydration error message actually tells us what to do:
Wrapping our component in <Suspense>
will allow us to catch this error
and re-render the component on the client.
Here’s what we’ve got so far:
'use client'
import { Suspense } from 'react'
import { useHydration } from 'hooks/useHydration'
export function LocalTime({ date }: { date: Date | string | number }) {
const hydrated = useHydration()
return (
<Suspense>
<time dateTime={new Date(date).toISOString()}>
{new Date(date).toLocaleTimeString()}
{hydrated ? '' : ' (UTC)'}
</time>
</Suspense>
)
}
But.. it still fails.
The key
to success
I believe the problem is that the <Suspense>
component is rendered only when
the component is first mounted, and not after the hydration pass.
We can solve this problem and get our final implementation by adding a key
prop to
the <Suspense>
component, and connecting it to the hydrated
value:
'use client'
import { Suspense } from 'react'
import { useHydration } from 'hooks/useHydration'
export function LocalTime({ date }: { date: DateLike }) {
const hydrated = useHydration()
return (
<Suspense key={hydrated ? 'local' : 'utc'}>
<time dateTime={new Date(date).toISOString()}>
{new Date(date).toLocaleTimeString()}
{hydrated ? '' : ' (UTC)'}
</time>
</Suspense>
)
}
The final source code for this component is available on GitHub. Go give the repo a star!
Freelance developer & founder