SolidJS の hydration mismatch を根絶する:原因パターン 12 と再発防止チェック

SolidJS アプリケーションを開発する際に直面する最も厄介な問題の一つが、hydration mismatch です。この問題は一見すると些細なエラーに見えますが、実際にはアプリケーションのパフォーマンスやユーザー体験に深刻な影響を与える可能性があります。
本記事では、SolidJS における hydration mismatch の根本原因を徹底的に分析し、12 の主要なパターンとその対策方法を詳しく解説いたします。さらに、再発防止のための具体的なチェックリストもご提供し、開発チーム全体でこの問題を根絶するための実践的なアプローチをお伝えします。
背景
SolidJS の hydration 仕組み
SolidJS の hydration プロセスは、サーバーサイドで生成された静的 HTML をクライアントサイドで動的なアプリケーションに変換する重要な仕組みです。
このプロセスでは、サーバーから送信された HTML の構造と、クライアントで実行される JavaScript コードによって生成される DOM の構造が完全に一致している必要があります。
mermaidflowchart TD
server[サーバーサイドレンダリング] --> html[静的HTML生成]
html --> client[クライアント受信]
client --> hydrate[hydration実行]
hydrate --> match{DOM一致?}
match -->|Yes| success[成功]
match -->|No| mismatch[Mismatch エラー]
図で理解できる要点:
- サーバーとクライアントでの二段階処理が必要
- DOM 構造の完全一致が成功の条件
- 不一致が発生すると即座にエラーとなる
SolidJS は他のフレームワークと比較して、よりパフォーマンスに最適化された hydration アプローチを採用しています。しかし、この最適化により、わずかな差異でもエラーが発生しやすくなっているのが現状です。
hydration mismatch が発生する理由
hydration mismatch が発生する根本的な理由は、サーバーサイドとクライアントサイドで異なる実行環境や条件により、同じコードが異なる結果を生成することにあります。
主な発生要因として以下が挙げられます:
環境差異による問題
- Node.js 環境とブラウザ環境での API 差異
- 利用可能なグローバル変数の違い
- タイムゾーンや地域設定の差異
実行タイミングの問題
- 非同期処理の完了タイミング
- 外部リソースの読み込み状況
- ネットワーク遅延の影響
状態管理の問題
- 初期状態の設定方法
- 外部ストレージへのアクセス
- 動的に変化するデータの扱い
これらの要因により、開発時には正常に動作していたアプリケーションが、本番環境で hydration mismatch エラーを起こすケースが頻繁に発生します。
パフォーマンスへの影響
hydration mismatch が発生すると、SolidJS は自動的に完全な再レンダリングを実行します。これにより以下のような深刻なパフォーマンス問題が発生します。
初期表示の遅延
- hydration の失敗により追加の再レンダリング処理が実行される
- ユーザーに表示されるまでの時間が大幅に延びる
- 特にモバイル環境での影響が顕著
リソース消費の増大
- CPU リソースの無駄な消費
- メモリ使用量の一時的な増加
- バッテリー消費への影響
ユーザー体験の悪化
- 画面のちらつきや一瞬の空白表示
- インタラクティブになるまでの時間延長
- SEO パフォーマンスへの悪影響
これらの影響は、アプリケーションの規模が大きくなるほど深刻になり、ビジネス上の損失にも直結する可能性があります。
課題
一般的な hydration mismatch の症状
hydration mismatch が発生すると、開発者は以下のような症状に遭遇することになります。これらの症状を正確に理解することで、問題の早期発見と適切な対処が可能になります。
コンソールエラーの発生 最も一般的な症状として、ブラウザのコンソールに以下のようなエラーメッセージが表示されます:
typescript// 典型的なhydration mismatchエラー
Error: Hydration mismatch: expected server HTML to contain matching elements
視覚的な症状
- ページの一瞬のちらつき(FOUC: Flash of Unstyled Content)
- コンテンツの一時的な消失
- スタイルの適用遅延
- レイアウトの崩れ
機能的な問題
- イベントハンドラーの動作不良
- 状態管理の初期化失敗
- インタラクティブ要素の応答遅延
これらの症状は、ユーザーに直接的な影響を与えるため、発見次第即座に対処する必要があります。
デバッグの困難さ
hydration mismatch のデバッグが困難な理由は、問題が複数の要因によって引き起こされることが多く、再現条件が複雑になることにあります。
再現の困難性
- 環境依存の問題が多い
- タイミング依存の問題が存在する
- 本番環境でのみ発生するケースがある
エラー情報の不足
- 具体的な原因箇所の特定が困難
- スタックトレースが深すぎる
- 関連するコード範囲が広い
開発ツールの制約
- 既存のデバッグツールでは検出できない
- ブラウザの開発者ツールでの分析に限界がある
- ソースマップの対応が不完全
これらの制約により、問題の解決に多大な時間を要することが頻繁に発生します。
既存の対策手法の限界
従来の hydration mismatch 対策には、以下のような限界があります。
局所的な対処法の問題 多くの開発者は、問題が発生した箇所のみを修正する傾向がありますが、これでは根本的な解決に至りません:
typescript// 問題のあるアプローチ例
function ProblematicComponent() {
// クライアントサイドでのみ実行される処理
const [mounted, setMounted] = createSignal(false);
onMount(() => {
setMounted(true);
});
// この方法では根本解決にならない
if (!mounted()) return null;
return <div>{new Date().toString()}</div>;
}
包括的な対策の不足
- チーム全体での知識共有不足
- 体系化された予防策の欠如
- 継続的なモニタリング体制の不備
ツール・ライブラリ依存の限界
- 外部ツールに頼りすぎる傾向
- カスタマイズの困難さ
- パフォーマンスへの追加負荷
これらの限界を克服するためには、より包括的で体系的なアプローチが必要です。
解決策
12 の原因パターンと対策
hydration mismatch の根本的な解決のため、発生頻度が高く影響の大きい 12 のパターンとその具体的な対策方法を詳しく解説します。
パターン 1:条件分岐での状態不一致
最も頻繁に発生するパターンの一つが、サーバーとクライアントで異なる条件分岐結果による状態不一致です。
問題のあるコード例:
typescriptfunction UserProfile() {
// サーバーでは常にfalse、クライアントでは実際の認証状態
const isAuthenticated = () =>
localStorage.getItem('token') !== null;
return (
<div>
{isAuthenticated() ? (
<div>ようこそ、ユーザーさん</div>
) : (
<div>ログインしてください</div>
)}
</div>
);
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
function UserProfile() {
const [isAuthenticated, setIsAuthenticated] =
createSignal(false);
const [mounted, setMounted] = createSignal(false);
onMount(() => {
// クライアントサイドでのみ認証状態をチェック
const token = localStorage.getItem('token');
setIsAuthenticated(token !== null);
setMounted(true);
});
// hydration完了まで共通の初期状態を表示
if (!mounted()) {
return <div>読み込み中...</div>;
}
return (
<div>
{isAuthenticated() ? (
<div>ようこそ、ユーザーさん</div>
) : (
<div>ログインしてください</div>
)}
</div>
);
}
対策のポイント:
- 初期状態は常にサーバーとクライアントで同一にする
- クライアント固有の状態は
onMount
内で設定する - loading 状態を適切に管理する
パターン 2:日時・ランダム値の使用
日時やランダム値を直接レンダリングに使用すると、サーバーとクライアントで必ず異なる値になるため、確実に mismatch が発生します。
問題のあるコード例:
typescriptfunction TimeDisplay() {
// サーバーとクライアントで実行時刻が異なる
const currentTime = new Date().toLocaleString();
return <div>現在時刻: {currentTime}</div>;
}
function RandomQuote() {
// 毎回異なる値が生成される
const quotes = ['格言1', '格言2', '格言3'];
const randomQuote =
quotes[Math.floor(Math.random() * quotes.length)];
return <div>今日の格言: {randomQuote}</div>;
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
function TimeDisplay() {
const [currentTime, setCurrentTime] =
createSignal('--:--:--');
onMount(() => {
// クライアントサイドでのみ時刻を設定
setCurrentTime(new Date().toLocaleString());
// 必要に応じて定期更新も設定
const interval = setInterval(() => {
setCurrentTime(new Date().toLocaleString());
}, 1000);
return () => clearInterval(interval);
});
return <div>現在時刻: {currentTime()}</div>;
}
typescriptimport { createSignal, onMount } from 'solid-js';
function RandomQuote() {
const quotes = ['格言1', '格言2', '格言3'];
const [selectedQuote, setSelectedQuote] = createSignal(
quotes[0]
);
onMount(() => {
// クライアントサイドでランダム選択
const randomIndex = Math.floor(
Math.random() * quotes.length
);
setSelectedQuote(quotes[randomIndex]);
});
return <div>今日の格言: {selectedQuote()}</div>;
}
対策のポイント:
- 時刻やランダム値は初期表示では固定値または代替表示を使用
- 動的な値の設定は
onMount
で実行 - 必要に応じてローディング状態を表示
パターン 3:環境変数の差異
サーバーとクライアントで利用可能な環境変数が異なることにより発生する mismatch です。
問題のあるコード例:
typescriptfunction ApiEndpoint() {
// Node.js環境でのみ利用可能な環境変数
const apiUrl =
process.env.INTERNAL_API_URL || 'http://localhost:3000';
return <div>API URL: {apiUrl}</div>;
}
修正されたコード例:
typescript// 環境変数の設定を統一化
const config = {
apiUrl:
typeof window !== 'undefined'
? import.meta.env.VITE_PUBLIC_API_URL
: process.env.INTERNAL_API_URL ||
'http://localhost:3000',
};
function ApiEndpoint() {
return <div>API URL: {config.apiUrl}</div>;
}
さらに良いアプローチ:
typescript// 設定ファイルで環境差異を吸収
// config/index.ts
export const getConfig = () => {
const isServer = typeof window === 'undefined';
return {
apiUrl: isServer
? process.env.INTERNAL_API_URL
: import.meta.env.VITE_PUBLIC_API_URL,
environment: isServer
? process.env.NODE_ENV
: import.meta.env.MODE,
};
};
// コンポーネントでの使用
import { getConfig } from './config';
function ApiEndpoint() {
const config = getConfig();
return <div>API URL: {config.apiUrl}</div>;
}
対策のポイント:
- 環境変数は事前に統一された設定ファイルで管理
- サーバー・クライアント判定を明確に行う
- 公開用と内部用の環境変数を適切に分離
パターン 4:非同期処理のタイミング
非同期処理の完了タイミングがサーバーとクライアントで異なることによる mismatch です。
問題のあるコード例:
typescriptimport { createResource } from 'solid-js';
function UserData() {
// サーバーとクライアントで非同期処理の完了タイミングが異なる
const [userData] = createResource(async () => {
const response = await fetch('/api/user');
return response.json();
});
return (
<div>
{userData.loading && <div>読み込み中...</div>}
{userData() && (
<div>ユーザー名: {userData().name}</div>
)}
</div>
);
}
修正されたコード例:
typescriptimport {
createSignal,
createResource,
onMount,
} from 'solid-js';
function UserData() {
const [shouldFetch, setShouldFetch] = createSignal(false);
// 条件付きでリソースを作成
const [userData] = createResource(() =>
shouldFetch()
? fetch('/api/user').then((r) => r.json())
: null
);
onMount(() => {
// クライアントサイドでのみデータ取得を開始
setShouldFetch(true);
});
return (
<div>
{userData.loading && <div>読み込み中...</div>}
{userData() && (
<div>ユーザー名: {userData().name}</div>
)}
{!userData.loading && !userData() && (
<div>データがありません</div>
)}
</div>
);
}
SSR 対応のより良いアプローチ:
typescriptimport { createResource } from 'solid-js';
function UserData(props: { initialData?: any }) {
const [userData] = createResource(
() => '/api/user',
async (url) => {
// 初期データがある場合はそれを使用
if (props.initialData) {
return props.initialData;
}
const response = await fetch(url);
return response.json();
},
{
// SSR時の初期値を設定
initialValue: props.initialData,
}
);
return (
<div>
{userData.loading && <div>読み込み中...</div>}
{userData() && (
<div>ユーザー名: {userData().name}</div>
)}
</div>
);
}
対策のポイント:
- 非同期処理は必要に応じて条件付きで実行
- 初期データがある場合は適切に活用
- loading 状態の管理を統一
パターン 5:ブラウザ固有 API の使用
ブラウザでのみ利用可能な API を直接使用することによる mismatch です。
問題のあるコード例:
typescriptfunction WindowSize() {
// window オブジェクトはサーバーサイドでは存在しない
const width = window.innerWidth;
const height = window.innerHeight;
return (
<div>
画面サイズ: {width} x {height}
</div>
);
}
function UserAgent() {
// navigator オブジェクトもサーバーサイドでは存在しない
const userAgent = navigator.userAgent;
return <div>ブラウザ: {userAgent}</div>;
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
function WindowSize() {
const [windowSize, setWindowSize] = createSignal({
width: 0,
height: 0,
});
onMount(() => {
// ブラウザAPI のアクセスはクライアントサイドでのみ
const updateSize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateSize();
window.addEventListener('resize', updateSize);
return () =>
window.removeEventListener('resize', updateSize);
});
return (
<div>
画面サイズ: {windowSize().width} x{' '}
{windowSize().height}
</div>
);
}
typescriptimport { createSignal, onMount } from 'solid-js';
function UserAgent() {
const [userAgent, setUserAgent] = createSignal('不明');
onMount(() => {
setUserAgent(navigator.userAgent);
});
return <div>ブラウザ: {userAgent()}</div>;
}
汎用的なカスタムフック:
typescriptimport { createSignal, onMount } from 'solid-js';
// ブラウザAPI を安全に使用するためのヘルパー
function useBrowserAPI<T>(
apiCall: () => T,
defaultValue: T
) {
const [value, setValue] = createSignal<T>(defaultValue);
onMount(() => {
try {
setValue(apiCall());
} catch (error) {
console.warn('Browser API access failed:', error);
}
});
return value;
}
// 使用例
function BrowserInfo() {
const userAgent = useBrowserAPI(
() => navigator.userAgent,
'User agent not available'
);
const windowSize = useBrowserAPI(
() => ({
width: window.innerWidth,
height: window.innerHeight,
}),
{ width: 0, height: 0 }
);
return (
<div>
<div>ブラウザ: {userAgent()}</div>
<div>
画面サイズ: {windowSize().width} x{' '}
{windowSize().height}
</div>
</div>
);
}
対策のポイント:
- ブラウザ API へのアクセスは
onMount
内で実行 - 適切なデフォルト値を設定
- エラーハンドリングを忘れずに実装
パターン 6:外部ライブラリの初期化
外部ライブラリの初期化タイミングや API の差異による mismatch です。
問題のあるコード例:
typescriptimport moment from 'moment';
function FormattedDate() {
// momentライブラリの初期化やタイムゾーン設定が環境によって異なる
const formattedDate = moment().format(
'YYYY-MM-DD HH:mm:ss'
);
return <div>日時: {formattedDate}</div>;
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
function FormattedDate() {
const [formattedDate, setFormattedDate] =
createSignal('日時を読み込み中...');
onMount(async () => {
// 動的インポートでクライアントサイドでのみライブラリを読み込み
const moment = (await import('moment')).default;
// 明示的にタイムゾーンや言語設定を指定
moment.locale('ja');
const formatted = moment().format(
'YYYY年MM月DD日 HH:mm:ss'
);
setFormattedDate(formatted);
});
return <div>{formattedDate()}</div>;
}
より安全なアプローチ(ライブラリ分離):
typescript// utils/dateFormatter.ts
export const formatDate = async (
date: Date
): Promise<string> => {
if (typeof window === 'undefined') {
// サーバーサイドでは標準APIを使用
return date.toLocaleDateString('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// クライアントサイドでは高機能なライブラリを使用
const moment = (await import('moment')).default;
moment.locale('ja');
return moment(date).format('YYYY年MM月DD日 HH:mm:ss');
};
// コンポーネントでの使用
import { createSignal, onMount } from 'solid-js';
import { formatDate } from './utils/dateFormatter';
function FormattedDate() {
const [formattedDate, setFormattedDate] =
createSignal('');
onMount(async () => {
const formatted = await formatDate(new Date());
setFormattedDate(formatted);
});
return (
<div>{formattedDate() || '日時を読み込み中...'}</div>
);
}
対策のポイント:
- 外部ライブラリの初期化は明示的に制御
- 動的インポートを活用してクライアントサイドのみで読み込み
- 環境に応じた代替実装を提供
パターン 7:CSS-in-JS の動的スタイル
CSS-in-JS ライブラリでの動的スタイル生成によるクラス名の不一致です。
問題のあるコード例:
typescriptimport { styled } from '@macaron-css/solid';
// 動的に生成されるクラス名がサーバーとクライアントで異なる
const DynamicButton = styled('button')`
background-color: ${(props) =>
props.primary ? '#007bff' : '#6c757d'};
color: white;
padding: 8px 16px;
`;
function ButtonComponent() {
const [isPrimary, setIsPrimary] = createSignal(
Math.random() > 0.5
);
return (
<DynamicButton primary={isPrimary()}>
ボタン
</DynamicButton>
);
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
// 事前定義されたCSSクラスを使用
const buttonStyles = {
primary: 'bg-blue-500 text-white px-4 py-2 rounded',
secondary: 'bg-gray-500 text-white px-4 py-2 rounded',
};
function ButtonComponent() {
const [isPrimary, setIsPrimary] = createSignal(false);
const [mounted, setMounted] = createSignal(false);
onMount(() => {
setIsPrimary(Math.random() > 0.5);
setMounted(true);
});
// hydration前は固定スタイルを適用
const currentStyle = mounted()
? isPrimary()
? buttonStyles.primary
: buttonStyles.secondary
: buttonStyles.secondary;
return <button class={currentStyle}>ボタン</button>;
}
CSS-in-JS ライブラリとの協調アプローチ:
typescript// styles/components.ts - 静的スタイル定義
export const createButtonStyle = (
variant: 'primary' | 'secondary'
) => {
const baseStyle = 'px-4 py-2 rounded transition-colors';
const variantStyles = {
primary: 'bg-blue-500 hover:bg-blue-600 text-white',
secondary: 'bg-gray-500 hover:bg-gray-600 text-white',
};
return `${baseStyle} ${variantStyles[variant]}`;
};
// コンポーネントでの使用
import { createSignal, onMount } from 'solid-js';
import { createButtonStyle } from './styles/components';
function ButtonComponent() {
const [variant, setVariant] = createSignal<
'primary' | 'secondary'
>('secondary');
onMount(() => {
// クライアントサイドでのみ動的に変更
setVariant(
Math.random() > 0.5 ? 'primary' : 'secondary'
);
});
return (
<button class={createButtonStyle(variant())}>
ボタン
</button>
);
}
対策のポイント:
- 動的スタイルの生成はクライアントサイドで実行
- 静的な CSS クラス定義を優先的に使用
- CSS-in-JS ライブラリの SSR 設定を適切に構成
パターン 8:イベントハンドラーの差異
サーバーサイドで存在しないイベントハンドラーの参照による mismatch です。
問題のあるコード例:
typescriptfunction InteractiveComponent() {
// サーバーサイドではwindow.alertは存在しない
const handleClick = () => {
window.alert('クリックされました');
};
return <button onClick={handleClick}>クリック</button>;
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
function InteractiveComponent() {
const [isClient, setIsClient] = createSignal(false);
onMount(() => {
setIsClient(true);
});
const handleClick = () => {
if (isClient()) {
alert('クリックされました');
}
};
return (
<button onClick={handleClick} disabled={!isClient()}>
{isClient() ? 'クリック' : '読み込み中...'}
</button>
);
}
より堅牢なアプローチ:
typescriptimport { createSignal, onMount } from 'solid-js';
// イベントハンドラーのラッパー関数
const createSafeHandler = <T extends any[]>(
handler: (...args: T) => void
) => {
return (...args: T) => {
if (typeof window !== 'undefined') {
try {
handler(...args);
} catch (error) {
console.error('Handler execution failed:', error);
}
}
};
};
function InteractiveComponent() {
const [message, setMessage] = createSignal('');
const handleClick = createSafeHandler(() => {
setMessage('ボタンがクリックされました!');
// ブラウザAPIを安全に使用
if ('vibrate' in navigator) {
navigator.vibrate(200);
}
});
const handleShare = createSafeHandler(async () => {
if ('share' in navigator) {
try {
await navigator.share({
title: 'SolidJS アプリ',
text: 'このアプリをチェックしてください!',
url: window.location.href,
});
} catch (error) {
console.log('Share cancelled or failed');
}
} else {
// フォールバック処理
setMessage('シェア機能は未対応です');
}
});
return (
<div>
<button onClick={handleClick}>通知</button>
<button onClick={handleShare}>シェア</button>
{message() && <p>{message()}</p>}
</div>
);
}
対策のポイント:
- イベントハンドラー内でブラウザ API を使用する際は適切なチェック
- エラーハンドリングを忘れずに実装
- サーバーサイドでも安全に実行できるハンドラー設計
パターン 9:メタデータの動的生成
動的に生成されるメタデータがサーバーとクライアントで異なることによる mismatch です。
問題のあるコード例:
typescriptimport { Title, Meta } from '@solidjs/meta';
function DynamicMetadata() {
// サーバーとクライアントで生成タイミングが異なる
const timestamp = Date.now();
const uniqueId = Math.random().toString(36).substr(2, 9);
return (
<div>
<Title>ページ - {timestamp}</Title>
<Meta
name='description'
content={`ページID: ${uniqueId}`}
/>
<h1>メインコンテンツ</h1>
</div>
);
}
修正されたコード例:
typescriptimport { Title, Meta } from '@solidjs/meta';
import { createSignal, onMount } from 'solid-js';
function DynamicMetadata(props: { initialTitle?: string }) {
const [pageTitle, setPageTitle] = createSignal(
props.initialTitle || 'ページ'
);
const [pageDescription, setPageDescription] =
createSignal('デフォルトの説明');
onMount(() => {
// クライアントサイドでメタデータを更新
const timestamp = new Date().toLocaleString();
const uniqueId = crypto.randomUUID();
setPageTitle(`ページ - ${timestamp}`);
setPageDescription(`ページID: ${uniqueId}`);
});
return (
<div>
<Title>{pageTitle()}</Title>
<Meta
name='description'
content={pageDescription()}
/>
<h1>メインコンテンツ</h1>
</div>
);
}
SEO 対応のより良いアプローチ:
typescriptimport { Title, Meta } from '@solidjs/meta';
import { createMemo } from 'solid-js';
interface PageMetadata {
title: string;
description: string;
keywords?: string;
}
function SEOOptimizedPage(props: {
staticMeta: PageMetadata;
dynamicData?: any;
}) {
// 静的メタデータを基本とし、必要に応じて動的要素を追加
const computedMeta = createMemo(() => {
const base = props.staticMeta;
if (props.dynamicData) {
return {
title: `${base.title} - ${props.dynamicData.category}`,
description: `${base.description} ${props.dynamicData.summary}`,
keywords: base.keywords,
};
}
return base;
});
return (
<div>
<Title>{computedMeta().title}</Title>
<Meta
name='description'
content={computedMeta().description}
/>
{computedMeta().keywords && (
<Meta
name='keywords'
content={computedMeta().keywords}
/>
)}
<h1>メインコンテンツ</h1>
</div>
);
}
対策のポイント:
- 初期メタデータは静的な値を使用
- 動的更新は
onMount
で実行 - SEO に重要なメタデータは事前に設定
パターン 10:ストレージアクセス
localStorage や sessionStorage へのアクセスによる mismatch です。
問題のあるコード例:
typescriptfunction UserPreferences() {
// サーバーサイドではlocalStorageは存在しない
const theme = localStorage.getItem('theme') || 'light';
const language = localStorage.getItem('language') || 'ja';
return (
<div class={`theme-${theme} lang-${language}`}>
<h1>ユーザー設定</h1>
<p>テーマ: {theme}</p>
<p>言語: {language}</p>
</div>
);
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
interface UserPreferences {
theme: 'light' | 'dark';
language: 'ja' | 'en';
}
function UserPreferencesComponent() {
const [preferences, setPreferences] =
createSignal<UserPreferences>({
theme: 'light',
language: 'ja',
});
const [loaded, setLoaded] = createSignal(false);
onMount(() => {
// ストレージから設定を読み込み
const savedTheme =
(localStorage.getItem('theme') as 'light' | 'dark') ||
'light';
const savedLanguage =
(localStorage.getItem('language') as 'ja' | 'en') ||
'ja';
setPreferences({
theme: savedTheme,
language: savedLanguage,
});
setLoaded(true);
});
const updateTheme = (newTheme: 'light' | 'dark') => {
setPreferences((prev) => ({
...prev,
theme: newTheme,
}));
localStorage.setItem('theme', newTheme);
};
const updateLanguage = (newLanguage: 'ja' | 'en') => {
setPreferences((prev) => ({
...prev,
language: newLanguage,
}));
localStorage.setItem('language', newLanguage);
};
return (
<div
class={`theme-${preferences().theme} lang-${
preferences().language
}`}
>
<h1>ユーザー設定</h1>
{!loaded() && <div>設定を読み込み中...</div>}
{loaded() && (
<>
<div>
<label>テーマ: </label>
<select
value={preferences().theme}
onChange={(e) =>
updateTheme(
e.target.value as 'light' | 'dark'
)
}
>
<option value='light'>ライト</option>
<option value='dark'>ダーク</option>
</select>
</div>
<div>
<label>言語: </label>
<select
value={preferences().language}
onChange={(e) =>
updateLanguage(
e.target.value as 'ja' | 'en'
)
}
>
<option value='ja'>日本語</option>
<option value='en'>English</option>
</select>
</div>
</>
)}
</div>
);
}
ストレージヘルパーの作成:
typescript// utils/storage.ts
export class SafeStorage {
private static isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
'localStorage' in window
);
}
static getItem<T>(key: string, defaultValue: T): T {
if (!this.isAvailable()) {
return defaultValue;
}
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.warn(
`Failed to parse localStorage item "${key}":`,
error
);
return defaultValue;
}
}
static setItem<T>(key: string, value: T): void {
if (!this.isAvailable()) {
return;
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.warn(
`Failed to set localStorage item "${key}":`,
error
);
}
}
static removeItem(key: string): void {
if (!this.isAvailable()) {
return;
}
try {
localStorage.removeItem(key);
} catch (error) {
console.warn(
`Failed to remove localStorage item "${key}":`,
error
);
}
}
}
// 使用例
import { createSignal, onMount } from 'solid-js';
import { SafeStorage } from './utils/storage';
function UserPreferencesWithHelper() {
const [theme, setTheme] = createSignal<'light' | 'dark'>(
'light'
);
onMount(() => {
const savedTheme = SafeStorage.getItem(
'theme',
'light'
);
setTheme(savedTheme);
});
const handleThemeChange = (
newTheme: 'light' | 'dark'
) => {
setTheme(newTheme);
SafeStorage.setItem('theme', newTheme);
};
return (
<div class={`theme-${theme()}`}>
<button
onClick={() =>
handleThemeChange(
theme() === 'light' ? 'dark' : 'light'
)
}
>
テーマ切り替え
</button>
</div>
);
}
対策のポイント:
- ストレージアクセスは必ず可用性をチェック
- デフォルト値を適切に設定
- エラーハンドリングを実装
パターン 11:ユーザーエージェント判定
ユーザーエージェントやブラウザ固有の機能判定による mismatch です。
問題のあるコード例:
typescriptfunction BrowserSpecificComponent() {
// サーバーサイドではnavigatorオブジェクトが存在しない
const isMobile = /Mobile|Android|iPhone/i.test(
navigator.userAgent
);
const isSafari =
/Safari/i.test(navigator.userAgent) &&
!/Chrome/i.test(navigator.userAgent);
return (
<div>
{isMobile ? (
<div>モバイル版コンテンツ</div>
) : (
<div>デスクトップ版コンテンツ</div>
)}
{isSafari && <div>Safari専用の機能</div>}
</div>
);
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
interface BrowserInfo {
isMobile: boolean;
isSafari: boolean;
isChrome: boolean;
supportsWebGL: boolean;
}
function BrowserSpecificComponent() {
const [browserInfo, setBrowserInfo] =
createSignal<BrowserInfo>({
isMobile: false,
isSafari: false,
isChrome: false,
supportsWebGL: false,
});
const [detected, setDetected] = createSignal(false);
onMount(() => {
// クライアントサイドでブラウザ情報を検出
const userAgent = navigator.userAgent;
const info: BrowserInfo = {
isMobile: /Mobile|Android|iPhone|iPad/i.test(
userAgent
),
isSafari:
/Safari/i.test(userAgent) &&
!/Chrome/i.test(userAgent),
isChrome: /Chrome/i.test(userAgent),
supportsWebGL: !!window.WebGLRenderingContext,
};
setBrowserInfo(info);
setDetected(true);
});
return (
<div>
{!detected() && <div>ブラウザ情報を検出中...</div>}
{detected() && (
<>
{browserInfo().isMobile ? (
<div>モバイル版コンテンツ</div>
) : (
<div>デスクトップ版コンテンツ</div>
)}
{browserInfo().isSafari && (
<div>Safari専用の機能</div>
)}
{browserInfo().supportsWebGL && (
<div>WebGL対応機能</div>
)}
</>
)}
</div>
);
}
CSS メディアクエリとの併用アプローチ:
typescriptimport { createSignal, onMount } from 'solid-js';
function ResponsiveComponent() {
const [jsBasedDetection, setJSBasedDetection] =
createSignal({
isMobile: false,
hasTouch: false,
});
onMount(() => {
// JavaScript ベースの検出(機能検出を優先)
setJSBasedDetection({
isMobile: window.matchMedia('(max-width: 768px)')
.matches,
hasTouch: 'ontouchstart' in window,
});
// メディアクエリの変更を監視
const mediaQuery = window.matchMedia(
'(max-width: 768px)'
);
const handleChange = (e: MediaQueryListEvent) => {
setJSBasedDetection((prev) => ({
...prev,
isMobile: e.matches,
}));
};
mediaQuery.addEventListener('change', handleChange);
return () =>
mediaQuery.removeEventListener(
'change',
handleChange
);
});
return (
<div class='responsive-container'>
{/* CSS メディアクエリによる基本レスポンシブ */}
<div class='hidden md:block'>
デスクトップ版(CSS)
</div>
<div class='block md:hidden'>モバイル版(CSS)</div>
{/* JavaScript による詳細制御 */}
{jsBasedDetection().hasTouch && (
<div>タッチ対応機能</div>
)}
</div>
);
}
対策のポイント:
- ユーザーエージェント判定はクライアントサイドで実行
- CSS メディアクエリと JavaScript の機能検出を併用
- 機能検出を可能な限り優先
パターン 12:数値・文字列の型変換
数値と文字列の変換処理における精度や形式の差異による mismatch です。
問題のあるコード例:
typescriptfunction NumericDisplay() {
// 小数点の精度や言語設定により結果が異なる可能性
const price = 1234.567;
const formattedPrice = price.toLocaleString();
// 日付の表示形式も環境により異なる
const date = new Date();
const formattedDate = date.toLocaleDateString();
return (
<div>
<div>価格: ¥{formattedPrice}</div>
<div>日付: {formattedDate}</div>
</div>
);
}
修正されたコード例:
typescriptimport { createSignal, onMount } from 'solid-js';
// 統一されたフォーマット関数
const formatters = {
currency: (value: number): string => {
return new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
}).format(value);
},
number: (value: number): string => {
return new Intl.NumberFormat('ja-JP').format(value);
},
date: (date: Date): string => {
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date);
},
};
function NumericDisplay() {
const [formattedData, setFormattedData] = createSignal({
price: '¥0',
date: '----年--月--日',
});
onMount(() => {
// クライアントサイドで明示的にフォーマット
const price = 1234.567;
const date = new Date();
setFormattedData({
price: formatters.currency(price),
date: formatters.date(date),
});
});
return (
<div>
<div>価格: {formattedData().price}</div>
<div>日付: {formattedData().date}</div>
</div>
);
}
より堅牢な型変換ヘルパー:
typescript// utils/formatters.ts
export class SafeFormatters {
private static locale = 'ja-JP';
private static timezone = 'Asia/Tokyo';
static currency(value: number | string): string {
try {
const numValue =
typeof value === 'string'
? parseFloat(value)
: value;
if (isNaN(numValue)) return '¥0';
return new Intl.NumberFormat(this.locale, {
style: 'currency',
currency: 'JPY',
}).format(numValue);
} catch (error) {
console.warn('Currency formatting failed:', error);
return '¥0';
}
}
static percentage(value: number | string): string {
try {
const numValue =
typeof value === 'string'
? parseFloat(value)
: value;
if (isNaN(numValue)) return '0%';
return new Intl.NumberFormat(this.locale, {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 2,
}).format(numValue / 100);
} catch (error) {
console.warn('Percentage formatting failed:', error);
return '0%';
}
}
static datetime(date: Date | string): string {
try {
const dateObj =
typeof date === 'string' ? new Date(date) : date;
if (isNaN(dateObj.getTime())) return '無効な日付';
return new Intl.DateTimeFormat(this.locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: this.timezone,
}).format(dateObj);
} catch (error) {
console.warn('Date formatting failed:', error);
return '無効な日付';
}
}
static fileSize(bytes: number): string {
try {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (
parseFloat((bytes / Math.pow(k, i)).toFixed(2)) +
' ' +
sizes[i]
);
} catch (error) {
console.warn('File size formatting failed:', error);
return '0 B';
}
}
}
// コンポーネントでの使用
import { createSignal, onMount } from 'solid-js';
import { SafeFormatters } from './utils/formatters';
function DataDisplay(props: {
price: number;
discount: number;
lastUpdated: string;
fileSize: number;
}) {
const [formattedData, setFormattedData] = createSignal({
price: 'フォーマット中...',
discount: 'フォーマット中...',
date: 'フォーマット中...',
size: 'フォーマット中...',
});
onMount(() => {
setFormattedData({
price: SafeFormatters.currency(props.price),
discount: SafeFormatters.percentage(props.discount),
date: SafeFormatters.datetime(
new Date(props.lastUpdated)
),
size: SafeFormatters.fileSize(props.fileSize),
});
});
return (
<div>
<div>価格: {formattedData().price}</div>
<div>割引: {formattedData().discount}</div>
<div>更新日時: {formattedData().date}</div>
<div>ファイルサイズ: {formattedData().size}</div>
</div>
);
}
対策のポイント:
- 数値フォーマットは明示的にロケールを指定
- エラーハンドリングを必ず実装
- 統一されたフォーマット関数を使用
再発防止のチェックリスト
hydration mismatch の再発を防ぐため、開発プロセスの各段階で活用できるチェックリストを提供します。
開発段階チェックリスト
コード記述時
- ブラウザ API の使用箇所で
typeof window !== 'undefined'
チェックを実装 -
localStorage
、sessionStorage
アクセス前に可用性確認を実施 - 日時・ランダム値の生成は
onMount
内で実行 - 条件分岐の初期値がサーバーとクライアントで同一であることを確認
- 外部ライブラリの初期化タイミングを明示的に制御
- 環境変数の参照方法を統一化
- CSS-in-JS の動的クラス生成を避け、事前定義されたクラスを使用
コンポーネント設計時
-
onMount
フックの適切な活用 - loading 状態の管理を実装
- エラーハンドリングの実装
- プロップスによる初期値設定の検討
- SSR/CSR の実行パスの明確化
テスト段階チェックリスト
ユニットテスト
- サーバーサイド環境でのコンポーネントレンダリングテスト
- クライアントサイド環境での hydration テスト
- 異なるブラウザ環境でのテスト実行
- モックされた外部 API でのテスト
- エラー状態でのテスト
統合テスト
- 実際の SSR フローでの E2E テスト
- ネットワーク遅延シミュレーション
- 異なるタイムゾーンでのテスト
- 複数のユーザーエージェントでのテスト
パフォーマンステスト
- hydration の実行時間測定
- メモリ使用量の監視
- Core Web Vitals の確認
デプロイ段階チェックリスト
本番環境検証
- 本番環境での初回 hydration テスト
- CDN キャッシュの影響確認
- 異なる地域からのアクセステスト
- モバイル環境での動作確認
モニタリング設定
- hydration mismatch エラーの監視設定
- パフォーマンス指標の追跡
- エラーログの集約設定
- アラート設定の構成
長期保守チェックリスト
定期レビュー
- 月次での hydration 関連エラーレポート確認
- ライブラリアップデート時の影響確認
- 新機能追加時の hydration 影響評価
- チーム内でのナレッジ共有
改善継続
- パフォーマンス改善の実施
- 新しいベストプラクティスの導入
- ツール・ライブラリの評価と更新
- ドキュメントの更新
具体例
実際のプロジェクトでの修正事例
実際のプロジェクトで発生した hydration mismatch の修正プロセスを、具体的なケーススタディとして紹介します。
ケーススタディ 1:EC サイトの商品価格表示
問題の発生状況 ある EC サイトで、商品価格の表示が時々おかしくなり、ユーザーから「価格が一瞬変わる」という報告が寄せられました。
原因の特定 調査の結果、通貨フォーマットの処理でサーバーとクライアントの環境差異が原因でした:
typescript// 問題のあったコード
function ProductPrice({ price }: { price: number }) {
// サーバーでは英語ロケール、クライアントでは日本語ロケール
const formattedPrice = price.toLocaleString(undefined, {
style: 'currency',
currency: 'JPY',
});
return <span>{formattedPrice}</span>;
}
修正プロセス
ステップ 1: 問題の再現
typescript// 再現テスト用コード
describe('ProductPrice hydration', () => {
test('server and client render same output', () => {
// サーバー環境をシミュレート
Object.defineProperty(navigator, 'language', {
value: 'en-US',
configurable: true,
});
const serverOutput = renderToString(() => (
<ProductPrice price={1000} />
));
// クライアント環境をシミュレート
Object.defineProperty(navigator, 'language', {
value: 'ja-JP',
configurable: true,
});
const clientOutput = renderToString(() => (
<ProductPrice price={1000} />
));
expect(serverOutput).toBe(clientOutput);
});
});
ステップ 2: 修正実装
typescriptimport { createSignal, onMount } from 'solid-js';
function ProductPrice({ price }: { price: number }) {
const [formattedPrice, setFormattedPrice] =
createSignal('¥---');
const [isLoaded, setIsLoaded] = createSignal(false);
onMount(() => {
// 明示的にロケールを指定してフォーマット
const formatted = new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
}).format(price);
setFormattedPrice(formatted);
setIsLoaded(true);
});
return (
<span
class={isLoaded() ? 'price-loaded' : 'price-loading'}
>
{formattedPrice()}
</span>
);
}
ステップ 3: 改善されたアプローチ
typescript// より良い解決策: 共通のフォーマッター関数
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
}).format(amount);
};
function ProductPrice({ price }: { price: number }) {
const [displayPrice, setDisplayPrice] =
createSignal<string>('');
// SSR時は基本的なフォーマットを使用
const ssrPrice = `¥${price.toLocaleString('ja-JP')}`;
onMount(() => {
// クライアントサイドで正確なフォーマットを適用
setDisplayPrice(formatCurrency(price));
});
return <span>{displayPrice() || ssrPrice}</span>;
}
ケーススタディ 2:ダッシュボードのリアルタイム更新
問題の発生状況 管理画面のダッシュボードで、リアルタイムデータの表示において頻繁に hydration mismatch が発生していました。
原因の特定 WebSocket からのデータ更新と SSR の初期データの競合が原因でした。
修正前のコード:
typescriptfunction Dashboard() {
const [data, setData] = createSignal(initialData);
// WebSocket接続を即座に開始
const ws = new WebSocket('ws://localhost:8080/dashboard');
ws.onmessage = (event) => {
setData(JSON.parse(event.data));
};
return (
<div>
<h1>ダッシュボード</h1>
<div>データ: {JSON.stringify(data())}</div>
</div>
);
}
修正後のコード:
typescriptimport { createSignal, onMount, onCleanup } from 'solid-js';
function Dashboard({ initialData }: { initialData: any }) {
const [data, setData] = createSignal(initialData);
const [isConnected, setIsConnected] = createSignal(false);
onMount(() => {
// クライアントサイドでのみWebSocket接続を開始
const ws = new WebSocket(
'ws://localhost:8080/dashboard'
);
ws.onopen = () => {
setIsConnected(true);
};
ws.onmessage = (event) => {
try {
const newData = JSON.parse(event.data);
setData(newData);
} catch (error) {
console.error(
'Failed to parse WebSocket data:',
error
);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setIsConnected(false);
};
onCleanup(() => {
ws.close();
});
});
return (
<div>
<h1>ダッシュボード</h1>
<div class='connection-status'>
{isConnected() ? '🟢 接続中' : '🔴 未接続'}
</div>
<div>
<h2>データ</h2>
<pre>{JSON.stringify(data(), null, 2)}</pre>
</div>
</div>
);
}
Before/After コード比較
よくある問題パターンの Before/After を比較表で整理します:
パターン | Before(問題あり) | After(修正済み) | 改善ポイント |
---|---|---|---|
日時表示 | new Date().toString() | onMount で設定 | サーバー・クライアント同期 |
ストレージアクセス | 直接localStorage アクセス | 可用性チェック + onMount | 安全なアクセス |
条件分岐 | window.innerWidth > 768 | メディアクエリ + フォールバック | 環境非依存 |
外部ライブラリ | 即座に初期化 | 動的インポート + onMount | 環境差異の解消 |
デバッグツールの活用方法
hydration mismatch の発見と解決を効率化するためのツール活用方法を紹介します。
SolidJS DevTools の活用
typescript// 開発環境でのデバッグヘルパー
function withHydrationDebug<T>(
component: () => T,
name: string
): () => T {
return () => {
if (import.meta.env.DEV) {
console.group(`Hydration Debug: ${name}`);
console.log('Rendering component...');
const result = component();
console.log('Component rendered successfully');
console.groupEnd();
return result;
}
return component();
};
}
// 使用例
const DebuggedComponent = withHydrationDebug(
() => <MyComponent />,
'MyComponent'
);
カスタムエラーハンドリング
typescript// グローバルエラーハンドラーの設定
function setupHydrationErrorHandling() {
if (typeof window !== 'undefined') {
window.addEventListener('error', (event) => {
if (event.message.includes('Hydration')) {
console.error('Hydration mismatch detected:', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack,
});
// エラー追跡サービスに送信
if (window.analytics) {
window.analytics.track('Hydration Error', {
message: event.message,
url: window.location.href,
userAgent: navigator.userAgent,
});
}
}
});
}
}
// アプリケーション初期化時に実行
setupHydrationErrorHandling();
自動テストでの検証
typescript// Playwrightを使用したE2Eテスト例
import { test, expect } from '@playwright/test';
test('hydration mismatch detection', async ({ page }) => {
// コンソールエラーを監視
const errors: string[] = [];
page.on('console', (msg) => {
if (
msg.type() === 'error' &&
msg.text().includes('Hydration')
) {
errors.push(msg.text());
}
});
await page.goto('/dashboard');
// ページが完全に読み込まれるまで待機
await page.waitForLoadState('networkidle');
// hydration mismatchエラーが発生していないことを確認
expect(errors).toHaveLength(0);
// 重要な要素が正しく表示されることを確認
await expect(
page.locator('[data-testid="dashboard-content"]')
).toBeVisible();
});
まとめ
重要ポイントの整理
SolidJS における hydration mismatch の根絶には、以下の重要ポイントを押さえることが不可欠です。
根本原因の理解 hydration mismatch は技術的な問題である以上に、開発プロセスの問題でもあります。サーバーサイドとクライアントサイドの実行環境差異を正しく理解し、それに基づいた設計を行うことが最も重要です。
体系的なアプローチ 今回紹介した 12 のパターンは、相互に関連し合っています。一つの問題を修正する際に、他のパターンも同時に確認することで、より堅牢なアプリケーションを構築できます。
予防重視の開発 問題が発生してから修正するのではなく、事前に問題を防ぐ設計パターンと開発プロセスを確立することが、長期的な開発効率の向上につながります。
開発フローへの組み込み提案
hydration mismatch の再発防止を確実にするため、以下の開発フローの改善を提案します。
コードレビュープロセスの強化
チェック項目の標準化
- ブラウザ API 使用箇所の確認
onMount
フックの適切な使用- 条件分岐の初期値統一
- エラーハンドリングの実装
自動化可能な検証
- ESLint ルールの追加
- TypeScript 厳格設定の活用
- 静的解析ツールの導入
継続的インテグレーションの改善
テスト環境の拡充
- 複数ブラウザでの E2E テスト
- 異なるタイムゾーンでのテスト
- ネットワーク遅延シミュレーション
モニタリングの強化
- リアルタイムエラー監視
- パフォーマンス指標の追跡
- ユーザー体験品質の測定
チーム内ナレッジ共有
定期的な勉強会
- 新しい問題パターンの共有
- 修正事例の発表
- ベストプラクティスの更新
ドキュメントの整備
- 社内ガイドラインの作成
- トラブルシューティング手順書
- FAQ 集の維持
これらの改善により、hydration mismatch の問題を根本的に解決し、より安定した SolidJS アプリケーションの開発が可能になります。継続的な改善と学習により、チーム全体の技術レベル向上も期待できるでしょう。
関連リンク
公式ドキュメント
コミュニティリソース
関連ツール・ライブラリ
パフォーマンス・デバッグツール
テストツール
- article
SolidJS の hydration mismatch を根絶する:原因パターン 12 と再発防止チェック
- article
SolidJS のリアクティブ思考法:signal と effect を“脳内デバッグ”で理解
- article
SolidJS で認証機能を実装する:JWT・OAuth 入門
- article
SolidJS で SVG や Canvas を自在に操る
- article
SolidJS アドオン&エコシステム最新事情
- article
SolidJS のカスタムフック(create*系)活用事例集
- article
【比較検証】Convex vs Firebase vs Supabase:リアルタイム性・整合性・学習コストの最適解
- article
【徹底比較】Preact vs React 2025:バンドル・FPS・メモリ・DX を総合評価
- article
GPT-5-Codex vs Claude Code / Cursor 徹底比較:得意領域・精度・開発速度の違いを検証
- article
Astro × Cloudflare Workers/Pages:エッジ配信で超高速なサイトを構築
- article
【2025 年版】Playwright vs Cypress vs Selenium 徹底比較:速度・安定性・学習コストの最適解
- article
Apollo を最短導入:Vite/Next.js/Remix での初期配線テンプレ集
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来