Zustand × SSR:サーバーサイドレンダリング対応の考え方と実装

モダンな Web アプリケーション開発において、サーバーサイドレンダリング(SSR)は重要な技術選択となっています。初期表示の高速化、SEO 対応、そしてユーザー体験の向上を実現するために、多くのプロジェクトで SSR が採用されるようになりました。
しかし、Zustand のようなクライアントサイドステート管理ライブラリを SSR 環境で使用する際には、独特の課題が存在します。サーバーとクライアント間でのステート同期、ハイドレーション時の整合性確保、そして適切な初期化タイミングの管理など、考慮すべき点が数多くあります。
この記事では、Zustand を SSR 対応アプリケーションで効果的に活用するための考え方と具体的な実装方法を詳しく解説してまいります。Next.js、Remix、SvelteKit、Nuxt.js といった主要な SSR フレームワークでの実装例も交えながら、実践的なソリューションをご提案いたします。
背景
SSR の仕組みと Zustand の標準動作
サーバーサイドレンダリングは、HTML をサーバー側で生成し、クライアントに送信する仕組みです。この過程では、JavaScript の実行環境がサーバーとクライアントで大きく異なるため、ステート管理において特別な配慮が必要になります。
SSR の基本的な流れ
SSR アプリケーションの動作は、以下のような段階を経て実行されます。
typescript// 1. サーバーサイドでの処理流れ
// server.ts(概念的な例)
async function handleRequest(request: Request) {
// サーバー上でのデータ取得
const initialData = await fetchDataFromDatabase();
// React ComponentをHTMLにレンダリング
const html = renderToString(
<App initialData={initialData} />
);
// HTMLとして送信
return new Response(html, {
headers: { 'Content-Type': 'text/html' },
});
}
// 2. クライアントサイドでのハイドレーション
// client.ts
import { hydrateRoot } from 'react-dom/client';
// サーバーから受け取ったHTMLに対してJavaScriptを適用
hydrateRoot(
document.getElementById('root'),
<App initialData={window.__INITIAL_DATA__} />
);
この流れで重要な点は、サーバーサイドで生成された HTML と、クライアントサイドでハイドレーションされるコンポーネントが完全に一致する必要があるということです。
Zustand の標準動作と SSR の衝突点
Zustand は基本的にクライアントサイド専用に設計されており、以下のような特徴があります。
typescript// Zustandの標準的な使用方法
import { create } from 'zustand';
interface UserState {
user: User | null;
isLoggedIn: boolean;
login: (userData: User) => void;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
isLoggedIn: false,
login: (userData) =>
set({
user: userData,
isLoggedIn: true,
}),
}));
// コンポーネントでの使用
function UserProfile() {
const { user, isLoggedIn } = useUserStore();
// この時点でSSRの問題が発生する可能性
if (!isLoggedIn) {
return <div>ログインしてください</div>;
}
return <div>こんにちは、{user?.name}さん</div>;
}
この実装では、以下の問題が発生します。
サーバーサイドでの実行:
useUserStore
はサーバー上では初期状態(user: null, isLoggedIn: false
)- 結果として「ログインしてください」の HTML が生成される
クライアントサイドでの実行:
- ローカルストレージから認証情報を復元
- ストアが更新されて
isLoggedIn: true
になる - 「こんにちは、○○ さん」のコンテンツが表示される
この差異により、ハイドレーションエラーが発生してしまいます。
クライアントサイドオンリーライブラリの SSR 課題
Zustand のようなクライアントサイド中心のライブラリを SSR 環境で使用する際の根本的な課題を詳しく見てみましょう。
ブラウザ固有 API への依存
多くのクライアントサイドライブラリは、ブラウザ環境でのみ利用可能な API に依存しています。
typescript// 問題のあるパターン:ブラウザ固有APIに直接依存
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{
name: 'theme-storage',
storage: localStorage, // サーバーサイドでは未定義
}
)
);
この実装を SSR 環境で実行すると、localStorage
が未定義であるため、以下のようなエラーが発生します。
javascriptReferenceError: localStorage is not defined
初期化タイミングの相違
クライアントサイドとサーバーサイドでは、アプリケーションの初期化タイミングが大きく異なります。
typescript// タイミングの問題を示す例
function App() {
const { user, fetchUserData } = useUserStore();
useEffect(() => {
// クライアントサイドでのみ実行される
if (!user) {
fetchUserData();
}
}, []);
// サーバーサイドではuserは常にnull
return user ? (
<UserDashboard user={user} />
) : (
<LoginForm />
);
}
この場合、サーバーサイドでは常に<LoginForm />
がレンダリングされますが、クライアントサイドでは認証済みユーザーに対して<UserDashboard />
が表示される可能性があり、コンテンツの不整合が生じます。
ハイドレーション過程でのステート管理の複雑さ
ハイドレーションは、サーバーで生成された静的 HTML に対して JavaScript の動的機能を付与する過程です。この過程でのステート管理は特に複雑になります。
ハイドレーション中のステート変化
typescript// ハイドレーション過程での複雑な状態遷移
interface AppState {
isHydrated: boolean;
userData: User | null;
preferences: UserPreferences | null;
}
export const useAppStore = create<AppState>((set, get) => ({
isHydrated: false,
userData: null,
preferences: null,
// ハイドレーション完了時の処理
onHydrationComplete: () => {
const { userData } = get();
// 永続化されたデータの復元
if (typeof window !== 'undefined') {
const savedData = localStorage.getItem('user-data');
if (savedData) {
set({
userData: JSON.parse(savedData),
isHydrated: true,
});
}
}
},
}));
この実装では、以下のような複雑な状態遷移が発生します。
- サーバーサイド:
isHydrated: false, userData: null
- 初回ハイドレーション: 同じ状態を維持(一致性確保)
- ハイドレーション後:
localStorage
から復元して状態更新 - 最終状態:
isHydrated: true, userData: {...}
段階的なコンテンツ表示
適切な SSR 対応のためには、段階的なコンテンツ表示戦略が必要になります。
typescriptfunction UserProfile() {
const { isHydrated, userData } = useAppStore();
// SSRとハイドレーション直後は同じコンテンツを表示
if (!isHydrated) {
return (
<div className='user-profile-skeleton'>
<div className='skeleton-avatar' />
<div className='skeleton-name' />
<div className='skeleton-email' />
</div>
);
}
// ハイドレーション完了後は実際のデータを表示
return userData ? (
<div className='user-profile'>
<img src={userData.avatar} alt='Avatar' />
<h2>{userData.name}</h2>
<p>{userData.email}</p>
</div>
) : (
<LoginPrompt />
);
}
この段階的表示により、ハイドレーションエラーを防ぎながら、ユーザー体験を向上させることができます。
非同期データとの同期
SSR 環境では、サーバーサイドで取得したデータとクライアントサイドのストアを適切に同期させる必要があります。
typescript// サーバーサイドデータとの同期パターン
interface ProductPageProps {
initialProduct: Product;
initialReviews: Review[];
}
function ProductPage({
initialProduct,
initialReviews,
}: ProductPageProps) {
const {
product,
reviews,
setInitialData,
isInitialized,
} = useProductStore();
useEffect(() => {
// サーバーから受け取った初期データでストアを初期化
if (!isInitialized) {
setInitialData({
product: initialProduct,
reviews: initialReviews,
});
}
}, [initialProduct, initialReviews, isInitialized]);
// 初期化前はサーバーデータを直接使用
const displayProduct = isInitialized
? product
: initialProduct;
const displayReviews = isInitialized
? reviews
: initialReviews;
return (
<div>
<ProductDetails product={displayProduct} />
<ReviewList reviews={displayReviews} />
</div>
);
}
このように、SSR 環境での Zustand 活用には、クライアントサイドオンリーの使用とは大きく異なる配慮が必要になります。次の章では、これらの課題を具体的に解決するための方法を詳しく解説してまいります。
課題
Zustand を SSR 環境で使用する際に直面する具体的な課題を、技術的な詳細とともに詳しく見ていきましょう。これらの課題を理解することで、適切な解決策を選択できるようになります。
サーバーとクライアントでのステート不整合
最も根本的な課題は、サーバーサイドとクライアントサイドでストアの状態が異なることです。この不整合は様々な形で現れます。
認証状態の不整合
典型的な例として、ユーザー認証状態の管理があります。
typescript// 認証ストアの例
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
login: (token: string, user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
isAuthenticated: false,
user: null,
token: null,
login: (token, user) =>
set({
isAuthenticated: true,
user,
token,
}),
logout: () =>
set({
isAuthenticated: false,
user: null,
token: null,
}),
}),
{
name: 'auth-storage',
}
)
);
この実装での問題:
サーバーサイドでの状態:
typescript{
isAuthenticated: false,
user: null,
token: null
}
クライアントサイド(localStorage 復元後):
typescript{
isAuthenticated: true,
user: { id: 1, name: "田中太郎" },
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
この状態の差により、以下のような問題が発生します。
typescriptfunction ProtectedRoute({
children,
}: {
children: React.ReactNode;
}) {
const { isAuthenticated } = useAuthStore();
// サーバー: false -> "ログインが必要です"をレンダリング
// クライアント: true -> childrenをレンダリング
if (!isAuthenticated) {
return <div>ログインが必要です</div>;
}
return <>{children}</>;
}
結果として発生するハイドレーションエラー:
arduinoWarning: Text content does not match. Server: "ログインが必要です" Client: "ダッシュボード"
設定値の不整合
ユーザー設定(テーマ、言語、表示設定など)でも同様の問題が発生します。
typescript// テーマストアの問題例
export const useThemeStore = create<ThemeState>()(
persist(
(set) => ({
theme: 'light', // デフォルト値
language: 'ja',
fontSize: 'medium',
setTheme: (theme) => set({ theme }),
}),
{
name: 'theme-storage',
}
)
);
function App() {
const { theme } = useThemeStore();
// サーバー: 'light' -> light テーマのCSSクラス
// クライアント: 'dark' -> dark テーマのCSSクラス(保存済み設定)
return (
<div className={`app ${theme}`}>
<Header />
<Main />
</div>
);
}
この場合、テーマに応じたスタイリングが不整合になり、視覚的なちらつき(FOUC: Flash of Unstyled Content)が発生します。
初期化タイミングの問題
SSR 環境では、ストアの初期化タイミングが複雑になります。適切なタイミングでの初期化を行わないと、データの欠損や重複取得が発生する可能性があります。
useEffect ベースの初期化の限界
従来のクライアントサイドでよく使われるパターンが、SSR 環境では適切に動作しません。
typescript// 問題のある初期化パターン
function UserDashboard() {
const { user, isLoading, fetchUser } = useUserStore();
useEffect(() => {
// この処理はクライアントサイドでのみ実行される
if (!user && !isLoading) {
fetchUser();
}
}, [user, isLoading]);
// サーバーサイドでは常にローディング状態またはnull状態
if (isLoading) {
return <LoadingSpinner />;
}
if (!user) {
return <div>ユーザー情報が見つかりません</div>;
}
return <UserProfile user={user} />;
}
この実装では以下の問題が発生します。
- サーバーサイド:
useEffect
が実行されないため、データ取得が行われない - 初回レンダリング: 「ユーザー情報が見つかりません」が表示される
- クライアントサイド:
useEffect
でデータ取得後、コンテンツが変更される
非同期データ取得のタイミング制御
より複雑な例として、複数の非同期処理が関わる場合を見てみましょう。
typescript// 複雑な初期化タイミングの例
interface DashboardState {
user: User | null;
projects: Project[];
notifications: Notification[];
isUserLoaded: boolean;
isProjectsLoaded: boolean;
isNotificationsLoaded: boolean;
}
export const useDashboardStore = create<DashboardState>(
(set, get) => ({
user: null,
projects: [],
notifications: [],
isUserLoaded: false,
isProjectsLoaded: false,
isNotificationsLoaded: false,
initializeDashboard: async () => {
// 順序依存性のある非同期処理
try {
// 1. ユーザー情報を取得
const user = await fetchUser();
set({ user, isUserLoaded: true });
// 2. ユーザーIDを使ってプロジェクトを取得
const projects = await fetchUserProjects(user.id);
set({ projects, isProjectsLoaded: true });
// 3. 通知を並行して取得
const notifications = await fetchUserNotifications(
user.id
);
set({ notifications, isNotificationsLoaded: true });
} catch (error) {
console.error(
'Dashboard initialization failed:',
error
);
}
},
})
);
このような複雑な初期化処理を SSR 環境で適切に制御するには、慎重な設計が必要になります。
ハイドレーションエラーの発生原因
ハイドレーションエラーは、サーバーサイドで生成された HTML とクライアントサイドでレンダリングされる JSX が一致しない場合に発生します。Zustand を使用する際の典型的な原因を詳しく見てみましょう。
条件分岐によるコンテンツの相違
typescript// ハイドレーションエラーを引き起こすパターン
function ConditionalContent() {
const { isFeatureEnabled } = useFeatureStore();
const { user } = useAuthStore();
// サーバー: isFeatureEnabled = false (デフォルト)
// クライアント: isFeatureEnabled = true (localStorage復元)
if (!isFeatureEnabled) {
return <div>この機能は利用できません</div>;
}
if (!user) {
return <div>ログインしてください</div>;
}
return <div>新機能: {user.name}さん、こんにちは!</div>;
}
このコンポーネントでは、複数の状態に依存した条件分岐により、サーバーとクライアントで異なるコンテンツがレンダリングされる可能性があります。
動的コンテンツの不整合
時刻や乱数など、レンダリング時に動的に生成される値も問題の原因となります。
typescript// 時刻に依存するコンテンツ
function WelcomeMessage() {
const { user } = useUserStore();
const { timezone } = useSettingsStore();
// サーバーとクライアントで実行時刻が異なる
const currentTime = new Date().toLocaleTimeString(
'ja-JP',
{
timeZone: timezone || 'Asia/Tokyo',
}
);
return (
<div>
{user && <p>{user.name}さん</p>}
<p>現在時刻: {currentTime}</p>
</div>
);
}
React.StrictMode との相互作用
開発環境での React.StrictMode は、ハイドレーションエラーをより厳格にチェックするため、Zustand との組み合わせで予期しないエラーが発生する場合があります。
typescript// StrictModeで問題となるパターン
function StrictModeProblematicComponent() {
const { data, fetchData } = useDataStore();
useEffect(() => {
// StrictModeでは useEffect が2回実行される
// 適切な依存関係の管理が重要
fetchData();
}, []); // 依存配列が不完全な場合に問題が発生
return <div>{data ? data.content : 'Loading...'}</div>;
}
SEO 対応での限界
SSR の重要な目的の一つが SEO 対応ですが、Zustand の使用方法によっては、SEO 効果が制限される場合があります。
クライアントサイドでのみ利用可能なコンテンツ
typescript// SEOで問題となるパターン
function ProductPage() {
const { products, isLoaded } = useProductStore();
const { userPreferences } = useUserStore();
useEffect(() => {
// クライアントサイドでのみデータを取得
loadProducts();
}, []);
if (!isLoaded) {
// サーバーサイドでは常にこの状態
return <div>商品を読み込み中...</div>;
}
// フィルタリングもクライアントサイドでのみ実行
const filteredProducts = products.filter((product) =>
userPreferences.categories.includes(product.category)
);
return (
<div>
<h1>商品一覧</h1>
{filteredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
この実装では、検索エンジンは「商品を読み込み中...」のテキストしか認識できず、実際の商品情報はインデックスされません。
メタデータの動的生成の問題
typescript// メタデータ生成での課題
function BlogPost({ postId }: { postId: string }) {
const { post, isLoading } = useBlogStore();
useEffect(() => {
loadPost(postId);
}, [postId]);
// サーバーサイドではpostがnullのため、適切なメタデータが生成されない
useEffect(() => {
if (post) {
document.title = post.title;
// メタタグの更新もクライアントサイドでのみ実行
updateMetaTags({
description: post.excerpt,
ogImage: post.featuredImage,
});
}
}, [post]);
if (isLoading || !post) {
return <div>記事を読み込み中...</div>;
}
return (
<article>
<h1>{post.title}</h1>
<div
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}
構造化データの生成困難
SEO にとって重要な構造化データ(JSON-LD)の生成も、クライアントサイドのデータに依存する場合は困難になります。
typescript// 構造化データ生成の問題
function EventPage({ eventId }: { eventId: string }) {
const { event } = useEventStore();
// サーバーサイドではeventがnullのため構造化データが生成されない
const structuredData = event
? {
'@context': 'https://schema.org',
'@type': 'Event',
name: event.title,
startDate: event.startDate,
location: {
'@type': 'Place',
name: event.venue,
address: event.address,
},
}
: null;
return (
<>
{structuredData && (
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify(structuredData),
}}
/>
)}
<div>
{event ? (
<EventDetails event={event} />
) : (
<div>イベント情報を読み込み中...</div>
)}
</div>
</>
);
}
これらの課題を解決するためには、SSR と Zustand の特性を理解した上で、適切な実装パターンを採用する必要があります。次の章では、これらの課題に対する具体的な解決策を詳しく解説いたします。
解決策
前章で確認した課題に対する具体的な解決策を、実装可能なコード例とともに詳しく解説いたします。これらの解決策を適切に組み合わせることで、SSR 環境での Zustand 活用を実現できます。
ストアの条件付き初期化パターン
SSR 環境では、サーバーとクライアントの実行環境が異なるため、条件付きでストアを初期化する必要があります。
環境判定による初期化制御
typescript// ssr-safe-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// サーバーサイドかクライアントサイドかを判定
const isServerSide = typeof window === 'undefined';
interface AppState {
isHydrated: boolean;
user: User | null;
theme: 'light' | 'dark';
language: string;
// アクション
setUser: (user: User | null) => void;
setTheme: (theme: 'light' | 'dark') => void;
setLanguage: (language: string) => void;
hydrate: () => void;
}
export const useAppStore = create<AppState>()(
persist(
(set, get) => ({
isHydrated: false,
user: null,
theme: 'light',
language: 'ja',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
// ハイドレーション処理
hydrate: () => {
// クライアントサイドでのみ実行
if (!isServerSide) {
set({ isHydrated: true });
}
},
}),
{
name: 'app-storage',
// サーバーサイドでは永続化を無効化
storage: isServerSide
? {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
}
: undefined,
onRehydrateStorage: () => (state) => {
// 復元完了後の処理
state?.hydrate();
},
}
)
);
ハイドレーション完了の検知
typescript// hydration-provider.tsx
'use client';
import { useEffect } from 'react';
import { useAppStore } from './ssr-safe-store';
export function HydrationProvider({
children,
}: {
children: React.ReactNode;
}) {
const hydrate = useAppStore((state) => state.hydrate);
useEffect(() => {
// クライアントサイドでハイドレーション完了を通知
hydrate();
}, [hydrate]);
return <>{children}</>;
}
// app/layout.tsx または _app.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='ja'>
<body>
<HydrationProvider>{children}</HydrationProvider>
</body>
</html>
);
}
段階的なコンテンツ表示
typescript// hydration-safe-component.tsx
import { useAppStore } from './ssr-safe-store';
export function HydrationSafeUserProfile() {
const { isHydrated, user, theme } = useAppStore();
// ハイドレーション完了前は静的コンテンツを表示
if (!isHydrated) {
return (
<div className='user-profile-placeholder'>
<div className='avatar-placeholder' />
<div className='name-placeholder'>ユーザー</div>
<div className='status-placeholder'>
読み込み中...
</div>
</div>
);
}
// ハイドレーション完了後は動的コンテンツを表示
return (
<div className={`user-profile theme-${theme}`}>
{user ? (
<>
<img src={user.avatar} alt='アバター' />
<h2>{user.name}</h2>
<p className='status'>オンライン</p>
</>
) : (
<>
<div className='default-avatar' />
<h2>ゲスト</h2>
<p className='status'>未ログイン</p>
</>
)}
</div>
);
}
サーバーサイドでのデータ注入手法
サーバーサイドで取得したデータを Zustand ストアに効率的に注入する方法を解説します。
Next.js での初期データ注入
typescript// server-data-injection.ts
interface ServerData {
user: User | null;
initialPosts: Post[];
siteSettings: SiteSettings;
}
interface DataInjectionState {
serverData: ServerData | null;
isServerDataLoaded: boolean;
injectServerData: (data: ServerData) => void;
clearServerData: () => void;
}
export const useDataInjectionStore =
create<DataInjectionState>((set) => ({
serverData: null,
isServerDataLoaded: false,
injectServerData: (data) =>
set({
serverData: data,
isServerDataLoaded: true,
}),
clearServerData: () =>
set({
serverData: null,
isServerDataLoaded: false,
}),
}));
// メインのアプリケーションストア
interface AppState {
user: User | null;
posts: Post[];
settings: SiteSettings;
isInitialized: boolean;
initializeFromServerData: (
serverData: ServerData
) => void;
}
export const useAppStore = create<AppState>((set) => ({
user: null,
posts: [],
settings: {
siteName: 'デフォルトサイト',
theme: 'light',
language: 'ja',
},
isInitialized: false,
initializeFromServerData: (serverData) => {
set({
user: serverData.user,
posts: serverData.initialPosts,
settings: serverData.siteSettings,
isInitialized: true,
});
},
}));
App Router での実装例
typescript// app/page.tsx
import { ServerDataProvider } from './server-data-provider';
async function getServerSideData(): Promise<ServerData> {
// サーバーサイドでのデータ取得
const [user, posts, settings] = await Promise.all([
getCurrentUser(),
getInitialPosts(),
getSiteSettings(),
]);
return {
user,
initialPosts: posts,
siteSettings: settings,
};
}
export default async function HomePage() {
const serverData = await getServerSideData();
return (
<ServerDataProvider data={serverData}>
<MainContent />
</ServerDataProvider>
);
}
typescript// server-data-provider.tsx
'use client';
import { useEffect } from 'react';
import {
useAppStore,
useDataInjectionStore,
} from './stores';
interface Props {
data: ServerData;
children: React.ReactNode;
}
export function ServerDataProvider({
data,
children,
}: Props) {
const initializeFromServerData = useAppStore(
(state) => state.initializeFromServerData
);
const injectServerData = useDataInjectionStore(
(state) => state.injectServerData
);
useEffect(() => {
// サーバーデータの注入
injectServerData(data);
initializeFromServerData(data);
}, [data, injectServerData, initializeFromServerData]);
return <>{children}</>;
}
Pages Router での実装例
typescript// pages/index.tsx
import { GetServerSideProps } from 'next';
import { ServerDataProvider } from '../components/server-data-provider';
interface Props {
serverData: ServerData;
}
export default function HomePage({ serverData }: Props) {
return (
<ServerDataProvider data={serverData}>
<MainContent />
</ServerDataProvider>
);
}
export const getServerSideProps: GetServerSideProps<
Props
> = async (context) => {
const serverData = await getServerSideData();
return {
props: {
serverData,
},
};
};
ハイドレーション安全な実装方法
ハイドレーションエラーを防ぐための実装パターンを詳しく解説します。
useIsomorphicLayoutEffect による安全な初期化
typescript// use-isomorphic-layout-effect.ts
import { useEffect, useLayoutEffect } from 'react';
// SSR環境ではuseLayoutEffectが警告を出すため、useEffectを使用
export const useIsomorphicLayoutEffect =
typeof window !== 'undefined'
? useLayoutEffect
: useEffect;
typescript// hydration-safe-hook.ts
import { useState } from 'react';
import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect';
export function useHydrationSafeState<T>(
initialValue: T,
hydratedValue?: T
) {
const [isHydrated, setIsHydrated] = useState(false);
const [value, setValue] = useState(initialValue);
useIsomorphicLayoutEffect(() => {
setIsHydrated(true);
if (hydratedValue !== undefined) {
setValue(hydratedValue);
}
}, [hydratedValue]);
return [
isHydrated ? value : initialValue,
setValue,
isHydrated,
] as const;
}
条件付きレンダリングの安全な実装
typescript// conditional-render-safe.tsx
import { useHydrationSafeState } from './hydration-safe-hook';
import { useAppStore } from './stores';
export function ConditionalRenderSafe() {
const { user, theme } = useAppStore();
const [isHydrated] = useHydrationSafeState(false, true);
// ハイドレーション完了前は共通のコンテンツを表示
if (!isHydrated) {
return (
<div className='app-container theme-light'>
<div className='user-section'>
<div className='placeholder-content'>
読み込み中...
</div>
</div>
</div>
);
}
// ハイドレーション完了後は動的コンテンツを表示
return (
<div className={`app-container theme-${theme}`}>
<div className='user-section'>
{user ? (
<UserProfile user={user} />
) : (
<LoginPrompt />
)}
</div>
</div>
);
}
段階的な表示制御
typescript// progressive-enhancement.tsx
interface ProgressiveEnhancementProps {
fallback: React.ReactNode;
children: React.ReactNode;
}
export function ProgressiveEnhancement({
fallback,
children,
}: ProgressiveEnhancementProps) {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
return isHydrated ? <>{children}</> : <>{fallback}</>;
}
// 使用例
function UserDashboard() {
const { user, isLoading } = useUserStore();
return (
<ProgressiveEnhancement
fallback={
<div className='dashboard-skeleton'>
<div className='skeleton-header' />
<div className='skeleton-content' />
<div className='skeleton-sidebar' />
</div>
}
>
{isLoading ? (
<LoadingSpinner />
) : user ? (
<DashboardContent user={user} />
) : (
<LoginRequired />
)}
</ProgressiveEnhancement>
);
}
エラーハンドリング戦略
SSR 環境での適切なエラーハンドリングを実装します。
ハイドレーションエラーの検知と処理
typescript// hydration-error-boundary.tsx
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
isHydrationError: boolean;
}
export class HydrationErrorBoundary extends Component<
Props,
State
> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
isHydrationError: false,
};
}
static getDerivedStateFromError(error: Error): State {
// ハイドレーションエラーの検知
const isHydrationError =
error.message.includes('Hydration') ||
error.message.includes('did not match') ||
error.message.includes('Text content does not match');
return {
hasError: true,
isHydrationError,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Hydration error:', error, errorInfo);
// エラー報告
if (this.state.isHydrationError) {
reportHydrationError(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
if (this.state.isHydrationError) {
// ハイドレーションエラーの場合は静的コンテンツを表示
return (
this.props.fallback || (
<div className='hydration-error-fallback'>
<p>ページを読み込み中です...</p>
</div>
)
);
}
// その他のエラーの場合
return (
<div className='error-boundary'>
<h2>エラーが発生しました</h2>
<button onClick={() => window.location.reload()}>
ページを再読み込み
</button>
</div>
);
}
return this.props.children;
}
}
function reportHydrationError(
error: Error,
errorInfo: ErrorInfo
) {
// エラー報告サービスに送信
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'hydration_error', {
error_message: error.message,
component_stack: errorInfo.componentStack,
});
}
}
ストアレベルでのエラーハンドリング
typescript// error-handling-store.ts
interface ErrorState {
errors: Record<string, string>;
isRecovering: boolean;
setError: (key: string, message: string) => void;
clearError: (key: string) => void;
clearAllErrors: () => void;
recoverFromError: () => Promise<void>;
}
export const useErrorStore = create<ErrorState>(
(set, get) => ({
errors: {},
isRecovering: false,
setError: (key, message) =>
set((state) => ({
errors: { ...state.errors, [key]: message },
})),
clearError: (key) =>
set((state) => {
const { [key]: removed, ...rest } = state.errors;
return { errors: rest };
}),
clearAllErrors: () => set({ errors: {} }),
recoverFromError: async () => {
set({ isRecovering: true });
try {
// ストアの状態をリセット
useAppStore.getState().reset?.();
// 必要に応じて再初期化
await useAppStore.getState().initialize?.();
set({ errors: {}, isRecovering: false });
} catch (error) {
set({
errors: { recovery: 'リカバリに失敗しました' },
isRecovering: false,
});
}
},
})
);
グレースフルデグラデーション
typescript// graceful-degradation.tsx
export function GracefulDegradation() {
const { user, isLoading, error } = useUserStore();
const { setError } = useErrorStore();
// エラー状態の場合の代替表示
if (error) {
return (
<div className='degraded-experience'>
<h2>一時的な問題が発生しています</h2>
<p>基本的な機能は利用できます。</p>
<StaticNavigation />
<StaticContent />
<button onClick={() => window.location.reload()}>
再試行
</button>
</div>
);
}
// 通常の動的コンテンツ
return (
<div className='full-experience'>
<DynamicNavigation user={user} />
<DynamicContent user={user} isLoading={isLoading} />
</div>
);
}
これらの解決策により、SSR 環境での Zustand 活用における主要な課題を解決できます。次の章では、具体的なフレームワーク別の実装例を詳しく見ていきましょう。
具体例
前章で説明した解決策を、実際の主要 SSR フレームワークでの具体的な実装例として詳しく解説いたします。
Next.js(App Router/Pages Router)での実装
Next.js は最も広く使われている React SSR フレームワークです。App Router と Pages Router それぞれでの実装方法を見ていきましょう。
App Router での実装
typescript// app/stores/blog-store.ts
import { create } from 'zustand';
import {
persist,
subscribeWithSelector,
} from 'zustand/middleware';
interface Post {
id: string;
title: string;
content: string;
publishedAt: string;
author: string;
}
interface BlogState {
posts: Post[];
currentPost: Post | null;
isLoading: boolean;
error: string | null;
isHydrated: boolean;
// Server data initialization
initializeFromServerData: (
posts: Post[],
currentPost?: Post
) => void;
// Client-side actions
loadPost: (id: string) => Promise<void>;
createPost: (post: Omit<Post, 'id'>) => Promise<void>;
updatePost: (
id: string,
updates: Partial<Post>
) => Promise<void>;
// Hydration control
setHydrated: () => void;
}
export const useBlogStore = create<BlogState>()(
subscribeWithSelector(
persist(
(set, get) => ({
posts: [],
currentPost: null,
isLoading: false,
error: null,
isHydrated: false,
initializeFromServerData: (posts, currentPost) => {
set({
posts,
currentPost: currentPost || null,
isLoading: false,
error: null,
});
},
loadPost: async (id) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(
`/api/posts/${id}`
);
if (!response.ok)
throw new Error('Failed to load post');
const post = await response.json();
set({ currentPost: post, isLoading: false });
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
isLoading: false,
});
}
},
createPost: async (postData) => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(postData),
});
if (!response.ok)
throw new Error('Failed to create post');
const newPost = await response.json();
set((state) => ({
posts: [...state.posts, newPost],
isLoading: false,
}));
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
isLoading: false,
});
}
},
updatePost: async (id, updates) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(
`/api/posts/${id}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates),
}
);
if (!response.ok)
throw new Error('Failed to update post');
const updatedPost = await response.json();
set((state) => ({
posts: state.posts.map((post) =>
post.id === id ? updatedPost : post
),
currentPost:
state.currentPost?.id === id
? updatedPost
: state.currentPost,
isLoading: false,
}));
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
isLoading: false,
});
}
},
setHydrated: () => set({ isHydrated: true }),
}),
{
name: 'blog-storage',
partialize: (state) => ({
// 永続化するデータを限定
posts: state.posts.slice(0, 10), // 最新10件のみ
}),
storage:
typeof window !== 'undefined'
? undefined
: {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
},
}
)
)
);
typescript// app/blog/page.tsx
import { BlogPostList } from './blog-post-list';
import { BlogDataProvider } from './blog-data-provider';
async function getBlogPosts(): Promise<Post[]> {
// サーバーサイドでのデータ取得
const response = await fetch(
'https://api.example.com/posts',
{
cache: 'revalidate',
next: { revalidate: 3600 }, // 1時間キャッシュ
}
);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
return response.json();
}
export default async function BlogPage() {
const posts = await getBlogPosts();
return (
<BlogDataProvider initialPosts={posts}>
<div className='blog-container'>
<h1>ブログ</h1>
<BlogPostList />
</div>
</BlogDataProvider>
);
}
export async function generateMetadata() {
const posts = await getBlogPosts();
return {
title: `ブログ - ${posts.length}件の記事`,
description: '最新のブログ記事をお楽しみください',
};
}
typescript// app/blog/blog-data-provider.tsx
'use client';
import { useEffect } from 'react';
import { useBlogStore } from '../stores/blog-store';
interface Props {
initialPosts: Post[];
children: React.ReactNode;
}
export function BlogDataProvider({
initialPosts,
children,
}: Props) {
const { initializeFromServerData, setHydrated } =
useBlogStore();
useEffect(() => {
// サーバーデータでストアを初期化
initializeFromServerData(initialPosts);
setHydrated();
}, [initialPosts, initializeFromServerData, setHydrated]);
return <>{children}</>;
}
typescript// app/blog/blog-post-list.tsx
'use client';
import { useBlogStore } from '../stores/blog-store';
export function BlogPostList() {
const { posts, isLoading, error, isHydrated } =
useBlogStore();
// ハイドレーション完了前はスケルトンを表示
if (!isHydrated) {
return (
<div className='blog-skeleton'>
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className='skeleton-post'>
<div className='skeleton-title' />
<div className='skeleton-content' />
<div className='skeleton-meta' />
</div>
))}
</div>
);
}
if (error) {
return (
<div className='error-message'>
<p>エラーが発生しました: {error}</p>
<button onClick={() => window.location.reload()}>
再試行
</button>
</div>
);
}
return (
<div className='post-list'>
{posts.map((post) => (
<article key={post.id} className='post-item'>
<h2>{post.title}</h2>
<p>{post.content.substring(0, 150)}...</p>
<div className='post-meta'>
<span>著者: {post.author}</span>
<span>
公開日:{' '}
{new Date(
post.publishedAt
).toLocaleDateString('ja-JP')}
</span>
</div>
</article>
))}
</div>
);
}
Pages Router での実装
typescript// pages/products/index.tsx
import { GetServerSideProps } from 'next';
import { ProductList } from '../../components/product-list';
import { ProductDataProvider } from '../../components/product-data-provider';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
interface Props {
initialProducts: Product[];
categories: string[];
}
export default function ProductsPage({
initialProducts,
categories,
}: Props) {
return (
<ProductDataProvider
initialProducts={initialProducts}
categories={categories}
>
<div className='products-page'>
<h1>商品一覧</h1>
<ProductList />
</div>
</ProductDataProvider>
);
}
export const getServerSideProps: GetServerSideProps<
Props
> = async (context) => {
try {
const [productsResponse, categoriesResponse] =
await Promise.all([
fetch('https://api.example.com/products'),
fetch('https://api.example.com/categories'),
]);
const [products, categories] = await Promise.all([
productsResponse.json(),
categoriesResponse.json(),
]);
return {
props: {
initialProducts: products,
categories: categories.map((cat: any) => cat.name),
},
};
} catch (error) {
console.error('Failed to fetch data:', error);
return {
props: {
initialProducts: [],
categories: [],
},
};
}
};
Remix での data loader 連携
Remix は Web 標準に基づいた SSR フレームワークで、loader と action の概念を持ちます。
typescript// app/stores/user-store.ts
import { create } from 'zustand';
interface User {
id: string;
name: string;
email: string;
preferences: UserPreferences;
}
interface UserState {
user: User | null;
isLoading: boolean;
error: string | null;
isHydrated: boolean;
initializeFromLoader: (user: User | null) => void;
updateUser: (updates: Partial<User>) => Promise<void>;
setHydrated: () => void;
}
export const useUserStore = create<UserState>(
(set, get) => ({
user: null,
isLoading: false,
error: null,
isHydrated: false,
initializeFromLoader: (user) => {
set({ user, isLoading: false, error: null });
},
updateUser: async (updates) => {
const currentUser = get().user;
if (!currentUser) return;
set({ isLoading: true, error: null });
try {
const response = await fetch(
`/api/users/${currentUser.id}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
}
);
if (!response.ok)
throw new Error('Failed to update user');
const updatedUser = await response.json();
set({ user: updatedUser, isLoading: false });
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
isLoading: false,
});
}
},
setHydrated: () => set({ isHydrated: true }),
})
);
typescript// app/routes/dashboard.tsx
import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { UserProfile } from '../components/user-profile';
import { UserDataProvider } from '../components/user-data-provider';
export async function loader({
request,
}: LoaderFunctionArgs) {
const userId = await getUserIdFromSession(request);
if (!userId) {
return json({ user: null });
}
try {
const user = await getUserById(userId);
return json({ user });
} catch (error) {
console.error('Failed to load user:', error);
return json({ user: null });
}
}
export default function DashboardPage() {
const { user } = useLoaderData<typeof loader>();
return (
<UserDataProvider initialUser={user}>
<div className='dashboard'>
<h1>ダッシュボード</h1>
<UserProfile />
</div>
</UserDataProvider>
);
}
typescript// app/components/user-data-provider.tsx
import { useEffect } from 'react';
import { useUserStore } from '../stores/user-store';
interface Props {
initialUser: User | null;
children: React.ReactNode;
}
export function UserDataProvider({
initialUser,
children,
}: Props) {
const { initializeFromLoader, setHydrated } =
useUserStore();
useEffect(() => {
initializeFromLoader(initialUser);
setHydrated();
}, [initialUser, initializeFromLoader, setHydrated]);
return <>{children}</>;
}
SvelteKit での load 関数との統合
SvelteKit での Zustand 使用例を見てみましょう。
typescript// src/lib/stores/product-store.ts
import { create } from 'zustand';
interface Product {
id: string;
name: string;
description: string;
price: number;
}
interface ProductState {
products: Product[];
selectedProduct: Product | null;
isLoading: boolean;
error: string | null;
isHydrated: boolean;
initializeFromPageData: (
products: Product[],
selectedProduct?: Product
) => void;
selectProduct: (product: Product) => void;
setHydrated: () => void;
}
export const useProductStore = create<ProductState>(
(set) => ({
products: [],
selectedProduct: null,
isLoading: false,
error: null,
isHydrated: false,
initializeFromPageData: (products, selectedProduct) => {
set({
products,
selectedProduct: selectedProduct || null,
isLoading: false,
error: null,
});
},
selectProduct: (product) => {
set({ selectedProduct: product });
},
setHydrated: () => set({ isHydrated: true }),
})
);
typescript// src/routes/products/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch }) => {
try {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const products = await response.json();
return {
products,
};
} catch (error) {
console.error('Load error:', error);
return {
products: [],
error: 'Failed to load products',
};
}
};
svelte<!-- src/routes/products/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { useProductStore } from '$lib/stores/product-store';
import type { PageData } from './$types';
export let data: PageData;
const store = useProductStore();
onMount(() => {
store.getState().initializeFromPageData(data.products);
store.getState().setHydrated();
});
$: products = $store.products;
$: isHydrated = $store.isHydrated;
</script>
{#if !isHydrated}
<div class="skeleton">
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
<div class="skeleton-item"></div>
</div>
{:else}
<div class="products">
<h1>商品一覧</h1>
{#each products as product (product.id)}
<div class="product-card">
<h2>{product.name}</h2>
<p>{product.description}</p>
<span class="price">¥{product.price.toLocaleString()}</span>
</div>
{/each}
</div>
{/if}
Nuxt.js での実装例
Nuxt.js での Zustand 活用方法を見てみましょう。
typescript// composables/useAuthStore.ts
import { create } from 'zustand';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
isHydrated: boolean;
initializeFromNuxtData: (user: User | null) => void;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => Promise<void>;
setHydrated: () => void;
}
export const useAuthStore = create<AuthState>(
(set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
isHydrated: false,
initializeFromNuxtData: (user) => {
set({
user,
isAuthenticated: !!user,
isLoading: false,
error: null,
});
},
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const { data } = await $fetch('/api/auth/login', {
method: 'POST',
body: credentials,
});
set({
user: data.user,
isAuthenticated: true,
isLoading: false,
});
await navigateTo('/dashboard');
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'ログインに失敗しました',
isLoading: false,
});
}
},
logout: async () => {
set({ isLoading: true });
try {
await $fetch('/api/auth/logout', {
method: 'POST',
});
set({
user: null,
isAuthenticated: false,
isLoading: false,
});
await navigateTo('/login');
} catch (error) {
set({ isLoading: false });
}
},
setHydrated: () => set({ isHydrated: true }),
})
);
vue<!-- pages/profile.vue -->
<template>
<div>
<AuthDataProvider :initial-user="data.user">
<div v-if="!isHydrated" class="skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-name"></div>
<div class="skeleton-email"></div>
</div>
<div v-else-if="isAuthenticated" class="profile">
<h1>プロフィール</h1>
<div class="user-info">
<img :src="user?.avatar" :alt="user?.name" />
<h2>{{ user?.name }}</h2>
<p>{{ user?.email }}</p>
</div>
<button @click="handleLogout">ログアウト</button>
</div>
<div v-else class="login-required">
<p>ログインが必要です</p>
<NuxtLink to="/login">ログインページへ</NuxtLink>
</div>
</AuthDataProvider>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/composables/useAuthStore';
// サーバーサイドでのデータ取得
const { data } = await useFetch('/api/auth/me', {
server: true,
default: () => ({ user: null }),
});
const store = useAuthStore();
const user = computed(() => store.getState().user);
const isAuthenticated = computed(
() => store.getState().isAuthenticated
);
const isHydrated = computed(
() => store.getState().isHydrated
);
const handleLogout = async () => {
await store.getState().logout();
};
</script>
vue<!-- components/AuthDataProvider.vue -->
<template>
<div>
<slot />
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '~/composables/useAuthStore';
interface Props {
initialUser: User | null;
}
const props = defineProps<Props>();
const store = useAuthStore();
onMounted(() => {
store
.getState()
.initializeFromNuxtData(props.initialUser);
store.getState().setHydrated();
});
</script>
まとめ
本記事では、Zustand を SSR 環境で効果的に活用するための考え方と実装方法について詳しく解説してまいりました。
重要なポイントの再確認
SSR 環境での Zustand 活用における重要な考慮点は以下の通りです。
-
環境判定による条件付き初期化: サーバーとクライアントの実行環境の違いを理解し、適切な条件分岐でストアを初期化する
-
ハイドレーション安全な実装: サーバーサイドとクライアントサイドで一致するコンテンツを最初に表示し、段階的に動的コンテンツに移行する
-
適切なデータ注入: サーバーサイドで取得したデータを効率的に Zustand ストアに注入するパターンを採用する
-
エラーハンドリング戦略: ハイドレーションエラーを検知し、適切に処理するエラーバウンダリーを実装する
フレームワーク別の実装パターン
各 SSR フレームワークでの実装パターンをまとめると以下のようになります。
フレームワーク | データ取得方法 | ストア初期化 | 特徴 |
---|---|---|---|
Next.js App Router | Server Components | Provider 経由 | RSC との統合 |
Next.js Pages Router | getServerSideProps | Provider 経由 | 従来の SSR パターン |
Remix | loader 関数 | useLoaderData 経由 | Web 標準ベース |
SvelteKit | load 関数 | PageData から初期化 | 軽量で高速 |
Nuxt.js | useFetch | composables 経由 | Vue.js エコシステム |
開発効率とパフォーマンスの両立
Zustand を SSR 環境で使用することで、以下の利点を享受できます。
開発効率の向上:
- ボイラープレートの削減
- 直感的な API 設計
- TypeScript との優れた統合
- デバッグの容易さ
パフォーマンスの最適化:
- 選択的な再レンダリング
- 効率的なメモリ使用
- バンドルサイズの最小化
- ハイドレーション時間の短縮
実装時のベストプラクティス
SSR 対応の Zustand ストアを実装する際は、以下のベストプラクティスを遵守することをお勧めします。
-
段階的なコンテンツ表示: ハイドレーション完了前は静的コンテンツを表示し、完了後に動的コンテンツに切り替える
-
適切なエラーハンドリング: ハイドレーションエラーを検知し、ユーザーに分かりやすいフィードバックを提供する
-
パフォーマンスモニタリング: Core Web Vitals を監視し、SSR の効果を定量的に評価する
-
プログレッシブエンハンスメント: 基本機能を静的コンテンツで提供し、JavaScript で段階的に機能を拡張する
今後の発展
Zustand とフレームワークの発展に伴い、SSR 対応もより洗練されていくと予想されます。特に以下の分野での進歩が期待されます。
- Server Components との更なる統合: React Server Components との自然な連携
- ストリーミング SSR の活用: 部分的なハイドレーションによる更なる高速化
- エッジコンピューティング対応: Vercel Edge や Cloudflare Workers での最適化
Zustand のシンプルさと SSR の強力さを組み合わせることで、高性能で保守しやすい Web アプリケーションを構築できます。本記事で紹介したパターンを参考に、プロジェクトの要件に最適な実装を選択していただければと思います。
関連リンク
- review
アジャイル初心者でも大丈夫!『アジャイルサムライ − 達人開発者への道』Jonathan Rasmusson
- review
人生が作品になる!『自分の中に毒を持て』岡本太郎
- review
体調不良の 99%が解決!『眠れなくなるほど面白い 図解 自律神経の話』小林弘幸著で学ぶ、現代人必須の自律神経コントロール術と人生を変える健康革命
- review
衝撃の事実!『睡眠こそ最強の解決策である』マシュー・ウォーカー著が明かす、99%の人が知らない睡眠の驚くべき真実と人生を変える科学的メカニズム
- review
人生が激変!『嫌われる勇気』岸見一郎・古賀史健著から学ぶ、アドラー心理学で手に入れる真の幸福と自己実現
- review
もう無駄な努力はしない!『イシューからはじめよ』安宅和人著で身につけた、99%の人が知らない本当に価値ある問題の見つけ方