Only the Poll Stringifies Date: getTime Is Not a Function
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
The same screen can disagree about the type of a Date field: SSR restores it as a Date, while a polling response through res.json() arrives as a string, and only Date-assuming code breaks there.
Conclusion
A Date field can have different types on different render paths within the same screen.
The initial SSR render (React Server Components) restores a Date as a Date. The REST API the screen polls separately does not preserve Dates: received through res.json(), a field that was a Date comes back as a plain string.
Hand that string to formatting code that calls .getTime() assuming a Date and it fails with TypeError: ...getTime is not a function.
Symptom
On always-on signage in a school corridor, boards of one particular pattern switched to a generic failure screen about ten seconds after startup.
- The initial render right after startup is fine. The error is not immediate
- Only boards whose pattern shows the news strip are affected. Patterns without news are untouched
- The browser log holds only
s.getTime is not a function - Reloading locally always renders the initial view correctly
The delay — “works at first, breaks shortly after” — combined with “only this pattern” made the cause hard to find.
Cause
The signage returns an initial SSR render and then polls its own API at intervals to refresh the content.
// SignageClient.tsx (pre-fix)
if (res.ok) {
setData((await res.json()) as SignagePayload);
}
SignagePayload includes fields typed as Date, such as the news publication date publishedAt. But res.json() only parses the HTTP JSON body, so what serialisation already turned into an ISO string comes back as a string.
The initial render (SSR into hydration) sailed past this. React Server Components’ serialisation carries Dates with type information, so on the client publishedAt was already a Date. Same field name, two render paths, two different arriving types.
The news strip formatted that publishedAt with this function.
// SignageBoardView.tsx (at the time)
function formatNewsDate(d: Date): string {
if (Number.isNaN(d.getTime())) {
return "";
}
return d.toLocaleDateString("ja-JP", {
timeZone: "Asia/Tokyo",
month: "numeric",
day: "numeric",
});
}
The parameter is annotated Date, but TypeScript’s types do not exist at runtime. The moment a string arrives through polling, the first line’s d.getTime() throws. The caller did not catch it, so the error boundary replaced that whole component region with an error screen. That is also why only boards drawing the news strip fell over.
“Breaks about ten seconds after startup” is because the initial view renders the SSR result untouched, and the string first arrives when the first poll returns.
The fix
Replace res.json() with parsing the body yourself and reviving only the Date fields.
// rotation.ts
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
export function reviveSignageDate(_key: string, value: unknown): unknown {
if (typeof value === "string" && ISO_DATETIME_RE.test(value)) {
return new Date(value);
}
return value;
}
// SignageClient.tsx (post-fix)
if (res.ok) {
setData(JSON.parse(await res.text(), reviveSignageDate) as SignagePayload);
}
The reviver’s regular expression targets date-times only — those with a T-separated time. The payload also carries YYYY-MM-DD fields such as forecastDate and an assignment deadline that are meant to stay strings; converting those too would break other code. Targeting only ISO strings with a time distinguishes what should become a Date from what should not.
With that, both render paths agree that “a Date is always a Date”, and formatNewsDate needed no change at all.
An assumption worth naming
The nastiness here is how hard it is to notice that two paths — SSR and a JSON API — use different serialisation behind the same screen and the same field name.
- SSR (RSC) can serialise a Date with type information
- Returning the same value as a REST JSON response makes it a plain string
- Testing or checking only the initial render never shows the difference. It surfaces the first time polling comes back
If you have data with Date fields feeding the same component through both the initial render and polling, it is worth confirming that the type actually arriving is the same on both paths. Trying only one and concluding “it works” is how you end up with a failure that reproduces on the other path only.
よくある質問
Q1When does 'getTime is not a function' appear?
When a value that should be a Date is actually a string and .getTime() is called on it. JSON has no Date type, so Dates always serialise to ISO strings. Without something to revive them on receipt, the string reaches Date-assuming code and only then throws.
Q2Why did SSR work while only the polling broke?
React Server Components use a different serialisation that restores a Date as a Date. The polling fetch takes a REST JSON response through res.json(), which returns plain strings. Same screen, same field, different render path, different type.
Q3What did the fix change?
It replaced res.json() with res.text() and JSON.parse(text, reviver). The reviver revives only ISO 8601 date-times (those with a T) into Dates, leaving date-only strings (YYYY-MM-DD) as strings. That makes the polling response agree with SSR that a Date is a Date.
Q4What happens if the TypeError goes unnoticed?
The calling component throws, so the error boundary swaps that whole section for an error screen. Here the entire visible board fell to a generic failure message seconds after startup while the initial render was fine — a shape that is hard to trace.
確認した環境
- Next.js 16 / React 19 (App Router, RSC)
- Occurred on production signage 2026-06-20, fixed the same day
この記事の根拠
- TypeScriptファイル 88〜110行目コミット 4c6b075
- TypeScriptファイル 60〜80行目コミット 4c6b075
- TypeScriptファイル 60〜75行目コミット 255c04e
- TypeScriptファイル 903〜912行目コミット 4c6b075
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。