T-CREATOR

Next.js × Jotai で作る SSR 対応のモダン Web アプリケーション

Next.js × Jotai で作る SSR 対応のモダン Web アプリケーション

Next.js と Jotai の組み合わせは、モダン Web アプリケーション開発において新たな可能性を切り開いています。特に SSR(Server-Side Rendering)対応のアプリケーション構築では、従来の状態管理ライブラリでは解決困難だった課題を、エレガントかつ効率的に解決できます。

本記事では、Next.js の強力な SSR 機能と Jotai の革新的な状態管理アプローチを組み合わせて、実用的なモダン Web アプリケーションを構築する方法を詳しく解説いたします。開発者の皆様にとって、新しい開発パラダイムの扉を開く内容となることでしょう。

Next.js と Jotai の組み合わせがもたらす開発体験の革新

従来の SSR 開発における課題

Web アプリケーション開発において、SSR は SEO やパフォーマンスの観点から重要な技術です。しかし、従来のアプローチでは多くの開発者が以下のような課題に直面していました。

#課題カテゴリ具体的な問題影響度
1状態同期サーバーとクライアント間の状態の不整合
2ハイドレーション初期化時のちらつきや表示崩れ
3パフォーマンス不要なデータフェッチングの発生
4開発効率複雑な状態管理ロジックの実装工数

これらの課題は、従来の状態管理ライブラリが SSR を前提とした設計になっていないことが根本的な原因でした。

Jotai がもたらす革新的なアプローチ

Jotai は、これらの課題を atomic な状態管理という革新的なアプローチで解決します。従来のグローバルストアとは根本的に異なる設計思想により、SSR 環境でも自然に状態を管理できるのです。

typescript// 従来のアプローチ(Redux)
const initialState = {
  user: null,
  posts: [],
  loading: false,
  error: null,
};

// サーバーサイドで初期状態を生成
export async function getServerSideProps(context) {
  const initialState = await fetchInitialData(context);
  return {
    props: {
      initialState,
    },
  };
}

// Jotai のアプローチ
const userAtom = atom(null);
const postsAtom = atom([]);
const loadingAtom = atom(false);

// 各 atom は独立して初期化可能
export async function getServerSideProps(context) {
  const userData = await fetchUser(context);
  const postsData = await fetchPosts(context);

  return {
    props: {
      initialUserData: userData,
      initialPostsData: postsData,
    },
  };
}

この違いは、単なる記述方法の違いではありません。アプリケーションの設計思想そのものが変わることを意味しているのです。

開発体験の劇的な向上

Jotai と Next.js の組み合わせによって実現される開発体験の向上は、以下の点で特に顕著です。

1. 直感的な状態定義

typescript// ユーザー情報の atom
const userAtom = atom<User | null>(null);

// 派生状態も簡潔に定義
const isLoggedInAtom = atom(
  (get) => get(userAtom) !== null
);
const userDisplayNameAtom = atom((get) => {
  const user = get(userAtom);
  return user
    ? `${user.firstName} ${user.lastName}`
    : 'ゲスト';
});

// 非同期操作も自然に表現
const userProfileAtom = atom(async (get) => {
  const user = get(userAtom);
  if (!user) return null;

  return await fetchUserProfile(user.id);
});

2. コンポーネントレベルでの最適化

typescript// 必要な状態のみを購読
function UserProfile() {
  const [user] = useAtom(userAtom);
  const [displayName] = useAtom(userDisplayNameAtom);

  if (!user) {
    return <LoginPrompt />;
  }

  return (
    <div className='user-profile'>
      <h2>こんにちは、{displayName}さん</h2>
      <UserSettings />
    </div>
  );
}

// 他のコンポーネントは影響を受けない
function UserSettings() {
  const [, setUser] = useAtom(userAtom);

  const updateProfile = async (newData: Partial<User>) => {
    const updatedUser = await updateUserProfile(newData);
    setUser(updatedUser);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* フォーム実装 */}
    </form>
  );
}

3. TypeScript との完全な統合

typescript// 型安全な atom 定義
interface AppState {
  user: User | null;
  theme: 'light' | 'dark';
  language: 'ja' | 'en';
}

const userAtom = atom<AppState['user']>(null);
const themeAtom = atom<AppState['theme']>('light');
const languageAtom = atom<AppState['language']>('ja');

// 型推論が効く派生状態
const localizedThemeAtom = atom((get) => {
  const theme = get(themeAtom);
  const language = get(languageAtom);

  return {
    theme,
    labels: getThemeLabels(language),
    cssVariables: getThemeCssVariables(theme),
  };
});

これらの特徴により、開発者は状態管理の複雑さに悩まされることなく、アプリケーションのコアロジックに集中できるようになります。

パフォーマンス面での革新

Jotai の atomic アプローチは、パフォーマンス面でも大きな革新をもたらします。

typescript// 従来のアプローチでの問題
const AppState = {
  user: {
    /* 大きなオブジェクト */
  },
  posts: [
    /* 大量のデータ */
  ],
  ui: {
    /* UI 状態 */
  },
};

// user が更新されると、posts や ui に依存しないコンポーネントも再レンダリング

// Jotai での解決
const userAtom = atom(null);
const postsAtom = atom([]);
const uiStateAtom = atom({});

// 各 atom の更新は独立して処理される
function PostsList() {
  const [posts] = useAtom(postsAtom); // user の更新では再レンダリングしない

  return (
    <div>
      {posts.map((post) => (
        <PostItem key={post.id} post={post} />
      ))}
    </div>
  );
}

この設計により、大規模なアプリケーションでも優れたパフォーマンスを維持できるのです。

SSR 対応の課題と Jotai による解決アプローチ

SSR 環境における状態管理の根本的課題

SSR 対応のアプリケーション開発では、サーバーサイドとクライアントサイドで同じコードが実行されるため、状態管理において特有の課題が発生します。

課題 1: ハイドレーション時の状態不整合

typescript// 問題のあるパターン
function ProblematicComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // クライアントサイドでのみ実行される
    fetchData().then(setData);
  }, []);

  // サーバー: data = null でレンダリング
  // クライアント: 初期は null、その後 fetchData の結果でレンダリング
  // → ハイドレーション エラーが発生

  return <div>{data ? data.title : 'Loading...'}</div>;
}

課題 2: サーバーサイドでの非同期データ処理

typescript// 従来のアプローチでの困難
export async function getServerSideProps(context) {
  // 複数の API を並列で呼び出し
  const [userData, postsData, configData] =
    await Promise.all([
      fetchUser(context.params.userId),
      fetchPosts(context.params.userId),
      fetchConfig(),
    ]);

  // グローバルストアの初期状態を構築
  const initialState = {
    user: userData,
    posts: postsData,
    config: configData,
    // ... 他の状態も含める必要がある
  };

  return {
    props: { initialState },
  };
}

課題 3: クライアントサイドでの状態同期

typescript// 複雑な同期処理が必要
function App({ initialState }) {
  const store = useStore();

  useEffect(() => {
    // サーバーから受け取った初期状態をクライアントのストアに同期
    store.dispatch({
      type: 'HYDRATE',
      payload: initialState,
    });
  }, []);

  // しかし、この時点で既にコンポーネントは初回レンダリング済み
  // 状態の不整合が発生する可能性がある
}

Jotai による革新的な解決アプローチ

Jotai は、これらの課題を Provider の initialValues 機能と atomic な状態管理により、エレガントに解決します。

解決法 1: Provider を使った初期値設定

typescript// pages/_app.tsx
function MyApp({ Component, pageProps }) {
  const { initialAtomValues = [] } = pageProps;

  return (
    <Provider initialValues={initialAtomValues}>
      <Component {...pageProps} />
    </Provider>
  );
}

// pages/user/[id].tsx
export async function getServerSideProps(context) {
  const userId = context.params.id;

  // 各データを並列取得
  const [userData, postsData] = await Promise.all([
    fetchUser(userId),
    fetchPosts(userId),
  ]);

  // Jotai の initialValues 形式で返す
  return {
    props: {
      initialAtomValues: [
        [userAtom, userData],
        [postsAtom, postsData],
      ],
    },
  };
}

解決法 2: Suspense と組み合わせた非同期処理

typescript// 非同期 atom の定義
const userProfileAtom = atom(async (get) => {
  const user = get(userAtom);
  if (!user) throw new Error('User not found');

  const response = await fetch(
    `/api/users/${user.id}/profile`
  );
  if (!response.ok)
    throw new Error('Failed to fetch profile');

  return response.json();
});

// コンポーネントでの使用
function UserProfile() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfileContent />
    </Suspense>
  );
}

function UserProfileContent() {
  const [profile] = useAtom(userProfileAtom);

  return (
    <div className='user-profile'>
      <img src={profile.avatar} alt={profile.name} />
      <h2>{profile.name}</h2>
      <p>{profile.bio}</p>
    </div>
  );
}

解決法 3: 段階的なデータ読み込み戦略

typescript// 基本データと詳細データを分離
const userBasicAtom = atom(null);
const userDetailAtom = atom(async (get) => {
  const user = get(userBasicAtom);
  if (!user) return null;

  // 基本データが読み込まれた後に詳細データを取得
  return await fetchUserDetails(user.id);
});

// ページコンポーネント
export default function UserPage({ initialUser }) {
  return (
    <Provider
      initialValues={[[userBasicAtom, initialUser]]}
    >
      <UserLayout>
        <UserBasicInfo />
        <Suspense fallback={<DetailSkeleton />}>
          <UserDetailInfo />
        </Suspense>
      </UserLayout>
    </Provider>
  );
}

export async function getServerSideProps(context) {
  // 基本データのみサーバーサイドで取得
  const basicUserData = await fetchUserBasic(
    context.params.id
  );

  return {
    props: {
      initialUser: basicUserData,
    },
  };
}

高度な SSR 最適化パターン

パターン 1: キャッシュ戦略との統合

typescript// SWR ライクなキャッシュ機能付き atom
const createCachedAtom = <T>(
  key: string,
  fetcher: () => Promise<T>
) => {
  return atom(async () => {
    // サーバーサイドでは毎回取得
    if (typeof window === 'undefined') {
      return await fetcher();
    }

    // クライアントサイドではキャッシュを確認
    const cached = sessionStorage.getItem(key);
    if (cached) {
      const { data, timestamp } = JSON.parse(cached);
      if (Date.now() - timestamp < 5 * 60 * 1000) {
        // 5分間有効
        return data;
      }
    }

    const data = await fetcher();
    sessionStorage.setItem(
      key,
      JSON.stringify({
        data,
        timestamp: Date.now(),
      })
    );

    return data;
  });
};

const userDataAtom = createCachedAtom('userData', () =>
  fetch('/api/user').then((res) => res.json())
);

パターン 2: 条件付きデータ取得

typescript// ユーザーの権限に基づく条件付き取得
const userPermissionsAtom = atom([]);
const adminDataAtom = atom(async (get) => {
  const permissions = get(userPermissionsAtom);

  // 管理者権限がない場合は null を返す
  if (!permissions.includes('admin')) {
    return null;
  }

  return await fetchAdminData();
});

// ページでの使用
export async function getServerSideProps(context) {
  const session = await getSession(context.req);

  if (!session) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      },
    };
  }

  const permissions = await getUserPermissions(
    session.userId
  );

  return {
    props: {
      initialAtomValues: [
        [userPermissionsAtom, permissions],
      ],
    },
  };
}

パターン 3: エラーハンドリング戦略

typescript// エラー状態を含む atom
const createAsyncAtomWithError = <T>(fetcher: () => Promise<T>) => {
  const dataAtom = atom<T | null>(null);
  const errorAtom = atom<Error | null>(null);
  const loadingAtom = atom<boolean>(false);

  const fetchAtom = atom(
    null,
    async (get, set) => {
      try {
        set(loadingAtom, true);
        set(errorAtom, null);
        const data = await fetcher();
        set(dataAtom, data);
      } catch (error) {
        set(errorAtom, error as Error);
      } finally {
        set(loadingAtom, false);
      }
    }
  );

  return {
    dataAtom,
    errorAtom,
    loadingAtom,
    fetchAtom
  };
};

// 使用例
const { dataAtom, errorAtom, loadingAtom, fetchAtom } =
  createAsyncAtomWithError(() => fetchUserData());

function UserDataComponent() {
  const [data] = useAtom(dataAtom);
  const [error] = useAtom(errorAtom);
  const [loading] = useAtom(loadingAtom);
  const [, fetch] = useAtom(fetchAtom);

  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} onRetry={fetch} />;
  if (!data) return <EmptyState onLoad={fetch} />;

  return <UserDisplay data={data} />;
}

これらのアプローチにより、Jotai は SSR 環境での状態管理を大幅に簡素化し、開発者がビジネスロジックに集中できる環境を提供します。

Next.js App Router と Pages Router での実装戦略

App Router (Next.js 13+) での実装戦略

Next.js の App Router は、新しいアーキテクチャにより、より柔軟で強力な SSR 実装を可能にします。Jotai との組み合わせでは、Server Components と Client Components の特性を活かした効率的な状態管理が実現できます。

App Router の基本構成

typescript// app/layout.tsx - ルートレイアウト
import { Provider } from 'jotai';
import { Suspense } from 'react';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <Provider>
          <Suspense fallback={<GlobalLoadingFallback />}>
            {children}
          </Suspense>
        </Provider>
      </body>
    </html>
  );
}

// app/providers.tsx - Client Components での Provider 設定
('use client');

import { Provider } from 'jotai';
import { ReactNode } from 'react';

interface ProvidersProps {
  children: ReactNode;
  initialValues?: any[];
}

export function Providers({
  children,
  initialValues = [],
}: ProvidersProps) {
  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

Server Components でのデータフェッチング戦略

typescript// app/users/[id]/page.tsx - Server Component
import { Suspense } from 'react';
import { UserProfile } from './user-profile';
import { UserPosts } from './user-posts';
import { Providers } from '../../providers';
import { userAtom, postsAtom } from '@/atoms/user';

async function getUserData(userId: string) {
  const response = await fetch(
    `${process.env.API_BASE_URL}/users/${userId}`,
    {
      next: { revalidate: 3600 }, // 1時間キャッシュ
    }
  );

  if (!response.ok) {
    throw new Error('Failed to fetch user data');
  }

  return response.json();
}

async function getUserPosts(userId: string) {
  const response = await fetch(
    `${process.env.API_BASE_URL}/users/${userId}/posts`,
    {
      next: { revalidate: 300 }, // 5分キャッシュ
    }
  );

  return response.json();
}

export default async function UserPage({
  params,
}: {
  params: { id: string };
}) {
  // Server Component 内で並列データフェッチ
  const [userData, postsData] = await Promise.all([
    getUserData(params.id),
    getUserPosts(params.id),
  ]);

  const initialValues = [
    [userAtom, userData],
    [postsAtom, postsData],
  ];

  return (
    <Providers initialValues={initialValues}>
      <div className='user-page'>
        <UserProfile />
        <Suspense fallback={<PostsLoadingFallback />}>
          <UserPosts />
        </Suspense>
      </div>
    </Providers>
  );
}

// Client Component でのデータ使用
('use client');

import { useAtom } from 'jotai';
import { userAtom } from '@/atoms/user';

export function UserProfile() {
  const [user] = useAtom(userAtom);

  return (
    <div className='user-profile'>
      <img src={user.avatar} alt={user.name} />
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

App Router での段階的データ読み込み

typescript// app/dashboard/page.tsx
import { DashboardProviders } from './providers';
import { DashboardLayout } from './layout';
import { Suspense } from 'react';

async function getDashboardData() {
  // 必要最小限のデータをサーバーサイドで取得
  const response = await fetch(
    `${process.env.API_BASE_URL}/dashboard/basic`
  );
  return response.json();
}

export default async function DashboardPage() {
  const basicData = await getDashboardData();

  return (
    <DashboardProviders initialData={basicData}>
      <DashboardLayout>
        <Suspense fallback={<WidgetsLoadingFallback />}>
          <DashboardWidgets />
        </Suspense>
      </DashboardLayout>
    </DashboardProviders>
  );
}

// dashboard/providers.tsx
('use client');

import { Provider } from 'jotai';
import {
  dashboardBasicAtom,
  dashboardWidgetsAtom,
} from '@/atoms/dashboard';

export function DashboardProviders({
  children,
  initialData,
}: {
  children: React.ReactNode;
  initialData: any;
}) {
  const initialValues = [[dashboardBasicAtom, initialData]];

  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

// Client Component での追加データ取得
('use client');

import { useAtom } from 'jotai';
import { dashboardWidgetsAtom } from '@/atoms/dashboard';

export function DashboardWidgets() {
  const [widgets] = useAtom(dashboardWidgetsAtom); // 非同期 atom

  return (
    <div className='dashboard-widgets'>
      {widgets.map((widget) => (
        <Widget key={widget.id} data={widget} />
      ))}
    </div>
  );
}

Pages Router での実装戦略

Pages Router では、従来の Next.js の機能を活用しつつ、Jotai の Provider パターンで効率的な状態管理を実現します。

Pages Router の基本構成

typescript// pages/_app.tsx
import type { AppProps } from 'next/app';
import { Provider } from 'jotai';

interface MyAppProps extends AppProps {
  pageProps: {
    initialAtomValues?: [any, any][];
  } & any;
}

export default function MyApp({
  Component,
  pageProps,
}: MyAppProps) {
  const { initialAtomValues = [], ...restPageProps } =
    pageProps;

  return (
    <Provider initialValues={initialAtomValues}>
      <Component {...restPageProps} />
    </Provider>
  );
}

getServerSideProps での実装パターン

typescript// pages/products/[id].tsx
import { GetServerSideProps } from 'next';
import {
  productAtom,
  reviewsAtom,
  relatedProductsAtom,
} from '@/atoms/product';

interface ProductPageProps {
  productId: string;
}

export default function ProductPage({
  productId,
}: ProductPageProps) {
  return (
    <div className='product-page'>
      <ProductDetail />
      <ProductReviews />
      <RelatedProducts />
    </div>
  );
}

export const getServerSideProps: GetServerSideProps =
  async (context) => {
    const { id } = context.params!;

    try {
      // 並列でデータを取得
      const [product, reviews, relatedProducts] =
        await Promise.all([
          fetchProduct(id as string),
          fetchProductReviews(id as string),
          fetchRelatedProducts(id as string),
        ]);

      return {
        props: {
          productId: id,
          initialAtomValues: [
            [productAtom, product],
            [reviewsAtom, reviews],
            [relatedProductsAtom, relatedProducts],
          ],
        },
      };
    } catch (error) {
      return {
        notFound: true,
      };
    }
  };

getStaticProps + ISR での実装パターン

typescript// pages/blog/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { articleAtom, commentsAtom } from '@/atoms/blog';

export default function BlogPost() {
  return (
    <article className='blog-post'>
      <BlogArticle />
      <BlogComments />
    </article>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const articles = await fetchArticleSlugs();

  return {
    paths: articles.map((slug) => ({ params: { slug } })),
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps = async (
  context
) => {
  const { slug } = context.params!;

  try {
    // 記事データは静的生成時に取得
    const article = await fetchArticle(slug as string);
    // コメントは動的に読み込むため初期値は空配列

    return {
      props: {
        initialAtomValues: [
          [articleAtom, article],
          [commentsAtom, []],
        ],
      },
      revalidate: 3600, // 1時間ごとに再生成
    };
  } catch (error) {
    return {
      notFound: true,
    };
  }
};

ハイブリッド実装戦略

App Router と Pages Router を混在させる場合の戦略も重要です。

ルーティング戦略の設計

typescript// 設定ファイルでのルーティング戦略
const routingStrategy = {
  // App Router を使用するルート
  appRoutes: ['/dashboard/*', '/admin/*', '/api/*'],

  // Pages Router を使用するルート
  pagesRoutes: ['/blog/*', '/products/*', '/legacy/*'],
};

// next.config.js
module.exports = {
  experimental: {
    appDir: true,
  },
  async rewrites() {
    return [
      // App Router への振り分け
      {
        source: '/dashboard/:path*',
        destination: '/app/dashboard/:path*',
      },
      // Pages Router への振り分け
      {
        source: '/blog/:path*',
        destination: '/pages/blog/:path*',
      },
    ];
  },
};

共通状態管理の実装

typescript// lib/atoms/shared.ts - 共通の atom 定義
export const userSessionAtom = atom(null);
export const appConfigAtom = atom({
  theme: 'light',
  language: 'ja',
});

// App Router でのプロバイダー設定
// app/layout.tsx
import { SharedProviders } from '@/components/providers/shared';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <SharedProviders>{children}</SharedProviders>
      </body>
    </html>
  );
}

// Pages Router でのプロバイダー設定
// pages/_app.tsx
import { SharedProviders } from '@/components/providers/shared';

export default function MyApp({ Component, pageProps }) {
  return (
    <SharedProviders
      initialValues={pageProps.initialAtomValues}
    >
      <Component {...pageProps} />
    </SharedProviders>
  );
}

// 共通プロバイダーコンポーネント
// components/providers/shared.tsx
('use client');

import { Provider } from 'jotai';
import { ReactNode } from 'react';

export function SharedProviders({
  children,
  initialValues = [],
}: {
  children: ReactNode;
  initialValues?: any[];
}) {
  return (
    <Provider initialValues={initialValues}>
      {children}
    </Provider>
  );
}

この実装戦略により、App Router と Pages Router のどちらを選択しても、Jotai の恩恵を最大限に活用できる柔軟なアーキテクチャを構築できます。

本格的な Web アプリケーション構築の実践手順

プロジェクトの初期セットアップ

実際のモダン Web アプリケーションを構築するための、体系的なセットアップ手順をご紹介します。

1. プロジェクトの作成と依存関係の設定

typescript# Next.js プロジェクトの作成(TypeScript テンプレート使用)
yarn create next-app@latest my-jotai-app --typescript --tailwind --eslint --app

cd my-jotai-app

# Jotai 関連の依存関係をインストール
yarn add jotai jotai-devtools

# 開発支援ツールの追加
yarn add -D @types/node prettier husky lint-staged

# UI コンポーネントライブラリ(例:Radix UI)
yarn add @radix-ui/react-dialog @radix-ui/react-dropdown-menu
yarn add @radix-ui/react-toast @radix-ui/react-form

# 状態管理支援ライブラリ
yarn add swr axios zod

# テスト関連
yarn add -D jest @testing-library/react @testing-library/jest-dom
yarn add -D @testing-library/user-event vitest jsdom

2. プロジェクト構造の設計

csharpsrc/
├── app/                    # App Router ページ
│   ├── (auth)/            # 認証が必要なページグループ
│   ├── (public)/          # 公開ページグループ
│   ├── api/               # API Routes
│   ├── globals.css        # グローバルスタイル
│   ├── layout.tsx         # ルートレイアウト
│   └── page.tsx           # ホームページ
├── atoms/                 # Jotai atoms の定義
│   ├── auth/              # 認証関連の atom
│   ├── ui/                # UI状態の atom
│   └── data/              # データ関連の atom
├── components/            # 再利用可能なコンポーネント
│   ├── ui/                # 基本UI コンポーネント
│   ├── features/          # 機能固有のコンポーネント
│   └── providers/         # Provider コンポーネント
├── hooks/                 # カスタム React hooks
├── lib/                   # ユーティリティと設定
│   ├── auth/              # 認証ロジック
│   ├── api/               # API クライアント
│   └── utils/             # 汎用ユーティリティ
├── types/                 # TypeScript 型定義
└── styles/                # スタイル関連ファイル

3. 基本的な Atom の設計と実装

typescript// atoms/auth/session.ts - 認証セッション管理
import { atom } from 'jotai';
import { z } from 'zod';

// ユーザー情報のスキーマ定義
const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string(),
  avatar: z.string().url().nullable(),
  role: z.enum(['user', 'admin']),
  createdAt: z.string().datetime(),
  lastLoginAt: z.string().datetime().nullable(),
});

export type User = z.infer<typeof UserSchema>;

// セッション状態の atom
export const userSessionAtom = atom<User | null>(null);

// ログイン状態の派生 atom
export const isLoggedInAtom = atom((get) => {
  const session = get(userSessionAtom);
  return session !== null;
});

// ユーザー権限チェックの派生 atom
export const isAdminAtom = atom((get) => {
  const session = get(userSessionAtom);
  return session?.role === 'admin';
});

// セッション管理の書き込み専用 atom
export const sessionActionsAtom = atom(
  null,
  (
    get,
    set,
    action: {
      type: 'login' | 'logout' | 'update';
      user?: User;
    }
  ) => {
    switch (action.type) {
      case 'login':
        if (action.user) {
          set(userSessionAtom, action.user);
          // ローカルストレージに保存
          localStorage.setItem(
            'user-session',
            JSON.stringify(action.user)
          );
        }
        break;
      case 'logout':
        set(userSessionAtom, null);
        localStorage.removeItem('user-session');
        break;
      case 'update':
        if (action.user) {
          set(userSessionAtom, action.user);
          localStorage.setItem(
            'user-session',
            JSON.stringify(action.user)
          );
        }
        break;
    }
  }
);

4. データ取得とキャッシュの実装

typescript// atoms/data/posts.ts - 投稿データ管理
import { atom } from 'jotai';
import { atomWithSuspenseQuery } from 'jotai-tanstack-query';
import { z } from 'zod';

// 投稿データのスキーマ
const PostSchema = z.object({
  id: z.string(),
  title: z.string(),
  content: z.string(),
  authorId: z.string(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
  tags: z.array(z.string()),
  isPublished: z.boolean(),
});

export type Post = z.infer<typeof PostSchema>;

// 投稿一覧のパラメータ atom
export const postsQueryParamsAtom = atom({
  page: 1,
  limit: 10,
  search: '',
  tag: null as string | null,
  sortBy: 'createdAt' as
    | 'createdAt'
    | 'updatedAt'
    | 'title',
  sortOrder: 'desc' as 'asc' | 'desc',
});

// 投稿一覧取得の非同期 atom
export const postsAtom = atomWithSuspenseQuery((get) => {
  const params = get(postsQueryParamsAtom);

  return {
    queryKey: ['posts', params],
    queryFn: async () => {
      const searchParams = new URLSearchParams({
        page: params.page.toString(),
        limit: params.limit.toString(),
        search: params.search,
        sortBy: params.sortBy,
        sortOrder: params.sortOrder,
        ...(params.tag && { tag: params.tag }),
      });

      const response = await fetch(
        `/api/posts?${searchParams}`
      );
      if (!response.ok) {
        throw new Error('投稿の取得に失敗しました');
      }

      const data = await response.json();
      return {
        posts: data.posts.map((post: any) =>
          PostSchema.parse(post)
        ),
        totalCount: data.totalCount,
        hasMore: data.hasMore,
      };
    },
    staleTime: 5 * 60 * 1000, // 5分間はキャッシュを使用
    gcTime: 10 * 60 * 1000, // 10分間メモリに保持
  };
});

// 個別投稿取得の atom ファクトリー
export const createPostAtom = (postId: string) => {
  return atomWithSuspenseQuery(() => ({
    queryKey: ['post', postId],
    queryFn: async () => {
      const response = await fetch(`/api/posts/${postId}`);
      if (!response.ok) {
        throw new Error('投稿の取得に失敗しました');
      }

      const data = await response.json();
      return PostSchema.parse(data);
    },
    staleTime: 10 * 60 * 1000, // 10分間はキャッシュを使用
  }));
};

5. UI 状態管理の実装

typescript// atoms/ui/modal.ts - モーダル状態管理
import { atom } from 'jotai';

interface ModalState {
  isOpen: boolean;
  type:
    | 'create-post'
    | 'edit-post'
    | 'delete-post'
    | 'user-profile'
    | null;
  data?: any;
}

export const modalStateAtom = atom<ModalState>({
  isOpen: false,
  type: null,
  data: undefined,
});

// モーダル操作用の書き込み専用 atom
export const modalActionsAtom = atom(
  null,
  (
    get,
    set,
    action:
      | {
          type: 'open';
          modalType: ModalState['type'];
          data?: any;
        }
      | { type: 'close' }
  ) => {
    const currentState = get(modalStateAtom);

    switch (action.type) {
      case 'open':
        set(modalStateAtom, {
          isOpen: true,
          type: action.modalType,
          data: action.data,
        });
        break;
      case 'close':
        set(modalStateAtom, {
          isOpen: false,
          type: null,
          data: undefined,
        });
        break;
    }
  }
);

// atoms/ui/toast.ts - トースト通知管理
interface Toast {
  id: string;
  type: 'success' | 'error' | 'warning' | 'info';
  title: string;
  description?: string;
  duration?: number;
}

export const toastsAtom = atom<Toast[]>([]);

export const toastActionsAtom = atom(
  null,
  (
    get,
    set,
    action:
      | { type: 'add'; toast: Omit<Toast, 'id'> }
      | { type: 'remove'; id: string }
      | { type: 'clear' }
  ) => {
    const currentToasts = get(toastsAtom);

    switch (action.type) {
      case 'add':
        const newToast: Toast = {
          ...action.toast,
          id: Math.random().toString(36).substring(2, 9),
        };
        set(toastsAtom, [...currentToasts, newToast]);

        // 自動削除の設定
        const duration = action.toast.duration ?? 5000;
        setTimeout(() => {
          set(toastsAtom, (prev) =>
            prev.filter((t) => t.id !== newToast.id)
          );
        }, duration);
        break;

      case 'remove':
        set(
          toastsAtom,
          currentToasts.filter((t) => t.id !== action.id)
        );
        break;

      case 'clear':
        set(toastsAtom, []);
        break;
    }
  }
);

実践的なコンポーネント実装

1. 認証機能付きレイアウトコンポーネント

typescript// components/layout/AuthenticatedLayout.tsx
'use client';

import { useAtom } from 'jotai';
import { useEffect, Suspense } from 'react';
import {
  userSessionAtom,
  sessionActionsAtom,
} from '@/atoms/auth/session';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { ToastContainer } from '@/components/ui/toast';

interface AuthenticatedLayoutProps {
  children: React.ReactNode;
}

export function AuthenticatedLayout({
  children,
}: AuthenticatedLayoutProps) {
  const [session] = useAtom(userSessionAtom);
  const [, sessionActions] = useAtom(sessionActionsAtom);

  useEffect(() => {
    // ページロード時にローカルストレージからセッションを復元
    const savedSession =
      localStorage.getItem('user-session');
    if (savedSession && !session) {
      try {
        const parsedSession = JSON.parse(savedSession);
        sessionActions({
          type: 'login',
          user: parsedSession,
        });
      } catch (error) {
        console.error(
          'セッションの復元に失敗しました:',
          error
        );
        localStorage.removeItem('user-session');
      }
    }
  }, [session, sessionActions]);

  if (!session) {
    return <LoginPage />;
  }

  return (
    <div className='min-h-screen bg-gray-50'>
      <Header />
      <div className='flex'>
        <Sidebar />
        <main className='flex-1 p-6'>
          <Suspense fallback={<PageLoadingFallback />}>
            {children}
          </Suspense>
        </main>
      </div>
      <ToastContainer />
    </div>
  );
}

// components/layout/Header.tsx
('use client');

import { useAtom } from 'jotai';
import {
  userSessionAtom,
  sessionActionsAtom,
} from '@/atoms/auth/session';
import { modalActionsAtom } from '@/atoms/ui/modal';

export function Header() {
  const [session] = useAtom(userSessionAtom);
  const [, sessionActions] = useAtom(sessionActionsAtom);
  const [, modalActions] = useAtom(modalActionsAtom);

  const handleLogout = () => {
    sessionActions({ type: 'logout' });
  };

  const handleProfileEdit = () => {
    modalActions({
      type: 'open',
      modalType: 'user-profile',
      data: session,
    });
  };

  return (
    <header className='bg-white shadow-sm border-b'>
      <div className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'>
        <div className='flex justify-between items-center h-16'>
          <div className='flex items-center'>
            <h1 className='text-xl font-bold text-gray-900'>
              My App
            </h1>
          </div>

          <div className='flex items-center space-x-4'>
            <span className='text-sm text-gray-700'>
              こんにちは、{session?.name}さん
            </span>
            <button
              onClick={handleProfileEdit}
              className='text-sm text-blue-600 hover:text-blue-800'
            >
              プロフィール
            </button>
            <button
              onClick={handleLogout}
              className='text-sm text-red-600 hover:text-red-800'
            >
              ログアウト
            </button>
          </div>
        </div>
      </div>
    </header>
  );
}

2. データ表示コンポーネント

typescript// components/features/posts/PostsList.tsx
'use client';

import { useAtom } from 'jotai';
import { Suspense } from 'react';
import {
  postsAtom,
  postsQueryParamsAtom,
} from '@/atoms/data/posts';
import { PostCard } from './PostCard';
import { PostsFilters } from './PostsFilters';
import { Pagination } from '@/components/ui/pagination';

export function PostsList() {
  return (
    <div className='space-y-6'>
      <div className='flex justify-between items-center'>
        <h2 className='text-2xl font-bold text-gray-900'>
          投稿一覧
        </h2>
        <CreatePostButton />
      </div>

      <PostsFilters />

      <Suspense fallback={<PostsLoadingSkeleton />}>
        <PostsContent />
      </Suspense>
    </div>
  );
}

function PostsContent() {
  const [{ posts, totalCount, hasMore }] =
    useAtom(postsAtom);
  const [queryParams, setQueryParams] = useAtom(
    postsQueryParamsAtom
  );

  const handlePageChange = (newPage: number) => {
    setQueryParams((prev) => ({ ...prev, page: newPage }));
  };

  if (posts.length === 0) {
    return (
      <div className='text-center py-12'>
        <p className='text-gray-500'>
          投稿が見つかりませんでした。
        </p>
      </div>
    );
  }

  return (
    <>
      <div className='grid gap-6 md:grid-cols-2 lg:grid-cols-3'>
        {posts.map((post) => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      <Pagination
        currentPage={queryParams.page}
        totalItems={totalCount}
        itemsPerPage={queryParams.limit}
        onPageChange={handlePageChange}
      />
    </>
  );
}

// components/features/posts/PostCard.tsx
import { Post } from '@/atoms/data/posts';
import { modalActionsAtom } from '@/atoms/ui/modal';
import { useAtom } from 'jotai';

interface PostCardProps {
  post: Post;
}

export function PostCard({ post }: PostCardProps) {
  const [, modalActions] = useAtom(modalActionsAtom);

  const handleEdit = () => {
    modalActions({
      type: 'open',
      modalType: 'edit-post',
      data: post,
    });
  };

  return (
    <div className='bg-white rounded-lg shadow-sm border p-6'>
      <div className='flex justify-between items-start mb-4'>
        <h3 className='text-lg font-medium text-gray-900 line-clamp-2'>
          {post.title}
        </h3>
        <button
          onClick={handleEdit}
          className='text-gray-400 hover:text-gray-600'
        >
          編集
        </button>
      </div>

      <p className='text-gray-600 text-sm line-clamp-3 mb-4'>
        {post.content}
      </p>

      <div className='flex items-center justify-between'>
        <div className='flex flex-wrap gap-2'>
          {post.tags.map((tag) => (
            <span
              key={tag}
              className='px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded'
            >
              {tag}
            </span>
          ))}
        </div>

        <span className='text-xs text-gray-500'>
          {new Date(post.createdAt).toLocaleDateString(
            'ja-JP'
          )}
        </span>
      </div>
    </div>
  );
}

これらの実践的な実装例により、Next.js と Jotai を使用した本格的な Web アプリケーションの基盤を構築できます。TypeScript による型安全性、Zod によるスキーマ検証、そして Jotai の atomic な状態管理が組み合わさることで、保守性と拡張性に優れたアプリケーションが実現されるのです。

SSR パフォーマンス最適化とハイドレーション制御

ハイドレーション最適化戦略

SSR アプリケーションにおいて、ハイドレーションは最も重要なパフォーマンス要因の一つです。Jotai と Next.js を組み合わせた場合の最適化手法をご紹介します。

1. 選択的ハイドレーション戦略

typescript// lib/hydration/selective-hydration.ts
import { atom } from 'jotai';

// ハイドレーション状態を管理する atom
export const hydrationStatusAtom = atom({
  isHydrated: false,
  hydratedComponents: new Set<string>(),
  pendingComponents: new Set<string>(),
});

// コンポーネント別のハイドレーション制御
export const createHydrationAtom = (
  componentId: string,
  priority: 'high' | 'low' = 'low'
) => {
  return atom(
    (get) => {
      const status = get(hydrationStatusAtom);
      return status.hydratedComponents.has(componentId);
    },
    (get, set) => {
      const status = get(hydrationStatusAtom);

      if (priority === 'high') {
        // 高優先度コンポーネントは即座にハイドレーション
        set(hydrationStatusAtom, {
          ...status,
          hydratedComponents: new Set([
            ...status.hydratedComponents,
            componentId,
          ]),
        });
      } else {
        // 低優先度コンポーネントは遅延ハイドレーション
        status.pendingComponents.add(componentId);

        requestIdleCallback(() => {
          set(hydrationStatusAtom, (prev) => ({
            ...prev,
            hydratedComponents: new Set([
              ...prev.hydratedComponents,
              componentId,
            ]),
            pendingComponents: new Set(
              [...prev.pendingComponents].filter(
                (id) => id !== componentId
              )
            ),
          }));
        });
      }
    }
  );
};

// ハイドレーション制御HOC
export function withSelectiveHydration<T extends {}>(
  Component: React.ComponentType<T>,
  componentId: string,
  priority: 'high' | 'low' = 'low'
) {
  return function SelectivelyHydratedComponent(props: T) {
    const [isHydrated, triggerHydration] = useAtom(
      createHydrationAtom(componentId, priority)
    );
    const [mounted, setMounted] = useState(false);

    useEffect(() => {
      setMounted(true);
      if (priority === 'high') {
        triggerHydration();
      }
    }, [triggerHydration, priority]);

    useEffect(() => {
      const observer = new IntersectionObserver(
        (entries) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting && !isHydrated) {
              triggerHydration();
            }
          });
        },
        { threshold: 0.1 }
      );

      const element = document.getElementById(componentId);
      if (element) {
        observer.observe(element);
      }

      return () => observer.disconnect();
    }, [componentId, isHydrated, triggerHydration]);

    if (!mounted) {
      return <div id={componentId}>Loading...</div>;
    }

    return (
      <div id={componentId}>
        {isHydrated ? (
          <Component {...props} />
        ) : (
          <ComponentSkeleton />
        )}
      </div>
    );
  };
}

2. 段階的データローディング

typescript// atoms/data/progressive-loading.ts
import { atom } from 'jotai';

// データ読み込みの優先度管理
interface LoadingStrategy {
  critical: string[];    // 即座に読み込み(Above the fold)
  important: string[];   // 2秒後に読み込み
  deferred: string[];    // ユーザーインタラクション後に読み込み
}

export const loadingStrategyAtom = atom<LoadingStrategy>({
  critical: ['user-session', 'navigation', 'page-content'],
  important: ['sidebar-data', 'recent-posts'],
  deferred: ['related-content', 'recommendations', 'comments']
});

// 段階的データ読み込み atom
export const createProgressiveAtom = <T>(
  key: string,
  fetcher: () => Promise<T>,
  fallbackValue: T
) => {
  const dataAtom = atom<T>(fallbackValue);
  const loadingAtom = atom<boolean>(false);
  const errorAtom = atom<Error | null>(null);

  const fetchAtom = atom(
    null,
    async (get, set) => {
      const strategy = get(loadingStrategyAtom);

      // 優先度に基づく遅延実行
      let delay = 0;
      if (strategy.important.includes(key)) {
        delay = 2000;
      } else if (strategy.deferred.includes(key)) {
        delay = 5000;
      }

      setTimeout(async () => {
        try {
          set(loadingAtom, true);
          set(errorAtom, null);

          const data = await fetcher();
          set(dataAtom, data);
        } catch (error) {
          set(errorAtom, error as Error);
        } finally {
          set(loadingAtom, false);
        }
      }, delay);
    }
  );

  return {
    dataAtom,
    loadingAtom,
    errorAtom,
    fetchAtom
  };
};

// 使用例
const { dataAtom: sidebarDataAtom, loadingAtom: sidebarLoadingAtom, fetchAtom: fetchSidebarAtom } =
  createProgressiveAtom('sidebar-data', fetchSidebarData, []);

function SidebarComponent() {
  const [data] = useAtom(sidebarDataAtom);
  const [loading] = useAtom(sidebarLoadingAtom);
  const [, fetch] = useAtom(fetchSidebarAtom);

  useEffect(() => {
    fetch();
  }, [fetch]);

  if (loading) return <SidebarSkeleton />;

  return (
    <aside className="sidebar">
      {data.map(item => (
        <SidebarItem key={item.id} item={item} />
      ))}
    </aside>
  );
}

3. メモリ使用量の最適化

typescript// lib/performance/memory-optimization.ts
import { atom } from 'jotai';

// メモリ使用量監視 atom
export const memoryUsageAtom = atom(0);

// LRU キャッシュ実装
class LRUCache<T> {
  private cache = new Map<string, T>();
  private readonly maxSize: number;

  constructor(maxSize: number = 50) {
    this.maxSize = maxSize;
  }

  get(key: string): T | undefined {
    const value = this.cache.get(key);
    if (value !== undefined) {
      // アクセスされたアイテムを最新に移動
      this.cache.delete(key);
      this.cache.set(key, value);
    }
    return value;
  }

  set(key: string, value: T): void {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // 最も古いアイテムを削除
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }

  clear(): void {
    this.cache.clear();
  }

  size(): number {
    return this.cache.size;
  }
}

// キャッシュ付き atom ファクトリー
export const createCachedAtom = <T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    maxCacheSize?: number;
    ttl?: number; // Time to live in milliseconds
  } = {}
) => {
  const { maxCacheSize = 20, ttl = 5 * 60 * 1000 } =
    options;
  const cache = new LRUCache<{
    data: T;
    timestamp: number;
  }>(maxCacheSize);

  return atom(async () => {
    const cached = cache.get(key);

    if (cached && Date.now() - cached.timestamp < ttl) {
      return cached.data;
    }

    const data = await fetcher();
    cache.set(key, { data, timestamp: Date.now() });

    return data;
  });
};

// メモリ監視と自動クリーンアップ
export const memoryMonitorAtom = atom(null, (get, set) => {
  const updateMemoryUsage = () => {
    if ('memory' in performance) {
      const memInfo = (performance as any).memory;
      const usage =
        memInfo.usedJSHeapSize / memInfo.totalJSHeapSize;
      set(memoryUsageAtom, usage);

      // メモリ使用量が80%を超えた場合の自動クリーンアップ
      if (usage > 0.8) {
        // キャッシュをクリア
        globalCache.clear();

        // ガベージコレクションの強制実行(可能であれば)
        if ('gc' in window) {
          (window as any).gc();
        }
      }
    }
  };

  // 定期的にメモリ使用量をチェック
  const interval = setInterval(updateMemoryUsage, 30000); // 30秒ごと

  return () => clearInterval(interval);
});

パフォーマンス監視とデバッグ

1. リアルタイム性能監視

typescript// lib/monitoring/performance-monitor.ts
import { atom } from 'jotai';

interface PerformanceMetrics {
  renderTime: number;
  componentCount: number;
  atomSubscriptions: number;
  memoryUsage: number;
  lastUpdate: number;
}

export const performanceMetricsAtom =
  atom<PerformanceMetrics>({
    renderTime: 0,
    componentCount: 0,
    atomSubscriptions: 0,
    memoryUsage: 0,
    lastUpdate: Date.now(),
  });

// パフォーマンス計測デコレータ
export function withPerformanceTracking<T extends {}>(
  Component: React.ComponentType<T>,
  componentName: string
) {
  return function TrackedComponent(props: T) {
    const [, setMetrics] = useAtom(performanceMetricsAtom);
    const renderStartTime = useRef<number>(0);

    useLayoutEffect(() => {
      renderStartTime.current = performance.now();
    });

    useEffect(() => {
      const renderTime =
        performance.now() - renderStartTime.current;

      setMetrics((prev) => ({
        ...prev,
        renderTime: Math.max(prev.renderTime, renderTime),
        componentCount: prev.componentCount + 1,
        lastUpdate: Date.now(),
      }));

      // Slow render warning
      if (renderTime > 16) {
        // 60fps threshold
        console.warn(
          `Slow render detected in ${componentName}: ${renderTime}ms`
        );
      }
    });

    return <Component {...props} />;
  };
}

// 開発用パフォーマンスダッシュボード
export function PerformanceDashboard() {
  const [metrics] = useAtom(performanceMetricsAtom);
  const [isVisible, setIsVisible] = useState(false);

  if (process.env.NODE_ENV !== 'development') {
    return null;
  }

  return (
    <div
      className={`performance-dashboard ${
        isVisible ? 'visible' : 'hidden'
      }`}
    >
      <button onClick={() => setIsVisible(!isVisible)}>
        📊 Performance
      </button>

      {isVisible && (
        <div className='metrics-panel'>
          <div>
            Render Time: {metrics.renderTime.toFixed(2)}ms
          </div>
          <div>Components: {metrics.componentCount}</div>
          <div>
            Atom Subscriptions: {metrics.atomSubscriptions}
          </div>
          <div>
            Memory Usage:{' '}
            {(metrics.memoryUsage * 100).toFixed(1)}%
          </div>
        </div>
      )}
    </div>
  );
}

プロダクション環境での運用ベストプラクティス

デプロイメント戦略

1. ビルド最適化設定

typescript// next.config.js
const nextConfig = {
  // Jotai の最適化設定
  experimental: {
    optimizePackageImports: ['jotai'],
  },

  // バンドル分析
  webpack: (
    config,
    {
      buildId,
      dev,
      isServer,
      defaultLoaders,
      nextRuntime,
      webpack,
    }
  ) => {
    // Jotai devtools を本番環境から除外
    if (!dev) {
      config.resolve.alias = {
        ...config.resolve.alias,
        'jotai-devtools': false,
      };
    }

    // Tree shaking の最適化
    config.optimization.usedExports = true;
    config.optimization.sideEffects = false;

    return config;
  },

  // 静的生成の最適化
  trailingSlash: false,
  poweredByHeader: false,

  // 画像最適化
  images: {
    domains: ['example.com'],
    formats: ['image/webp', 'image/avif'],
  },

  // ヘッダーの最適化
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-XSS-Protection',
            value: '1; mode=block',
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;

2. エラー監視とログ収集

typescript// lib/monitoring/error-tracking.ts
import { atom } from 'jotai';

interface ErrorLog {
  id: string;
  message: string;
  stack?: string;
  componentStack?: string;
  atomName?: string;
  userId?: string;
  timestamp: number;
  url: string;
  userAgent: string;
}

export const errorLogsAtom = atom<ErrorLog[]>([]);

export const errorHandlerAtom = atom(
  null,
  (
    get,
    set,
    error: Error & {
      componentStack?: string;
      atomName?: string;
    }
  ) => {
    const errorLog: ErrorLog = {
      id: Math.random().toString(36).substring(2, 9),
      message: error.message,
      stack: error.stack,
      componentStack: error.componentStack,
      atomName: error.atomName,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
    };

    // ローカルログ保存
    set(errorLogsAtom, (prev) => [
      ...prev.slice(-99),
      errorLog,
    ]); // 最新100件保持

    // 外部監視サービスに送信(例:Sentry, DataDog)
    if (
      typeof window !== 'undefined' &&
      process.env.NODE_ENV === 'production'
    ) {
      fetch('/api/log-error', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(errorLog),
      }).catch(console.error);
    }
  }
);

// Error Boundary for Jotai
export class JotaiErrorBoundary extends React.Component<
  {
    children: React.ReactNode;
    fallback?: React.ComponentType<{ error: Error }>;
  },
  { hasError: boolean; error?: Error }
> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(
    error: Error,
    errorInfo: React.ErrorInfo
  ) {
    // Jotai atom でエラーを処理
    import('./atoms/error').then(({ errorHandlerAtom }) => {
      // Atom を使ってエラーログを記録
      errorHandlerAtom.write({
        ...error,
        componentStack: errorInfo.componentStack,
      });
    });
  }

  render() {
    if (this.state.hasError) {
      const FallbackComponent =
        this.props.fallback || DefaultErrorFallback;
      return (
        <FallbackComponent error={this.state.error!} />
      );
    }

    return this.props.children;
  }
}

3. CI/CD パイプライン設定

yaml# .github/workflows/deploy.yml
name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'yarn'

      - name: Install dependencies
        run: yarn install --frozen-lockfile

      - name: Run tests
        run: yarn test

      - name: Type checking
        run: yarn type-check

      - name: Lint
        run: yarn lint

      - name: Build
        run: yarn build
        env:
          NODE_ENV: production

  deploy:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    steps:
      - uses: actions/checkout@v3

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID }}
          vercel-project-id: ${{ secrets.PROJECT_ID }}
          vercel-args: '--prod'

4. 監視とアラート設定

typescript// lib/monitoring/health-check.ts
import { atom } from 'jotai';

interface HealthStatus {
  status: 'healthy' | 'degraded' | 'unhealthy';
  checks: {
    database: boolean;
    api: boolean;
    memory: boolean;
    atomStore: boolean;
  };
  lastCheck: number;
}

export const healthStatusAtom = atom<HealthStatus>({
  status: 'healthy',
  checks: {
    database: true,
    api: true,
    memory: true,
    atomStore: true,
  },
  lastCheck: Date.now(),
});

export const healthCheckAtom = atom(
  null,
  async (get, set) => {
    const checks = {
      database: await checkDatabase(),
      api: await checkAPI(),
      memory: checkMemoryUsage(),
      atomStore: checkAtomStore(),
    };

    const failedChecks = Object.values(checks).filter(
      (check) => !check
    ).length;
    const status =
      failedChecks === 0
        ? 'healthy'
        : failedChecks <= 1
        ? 'degraded'
        : 'unhealthy';

    set(healthStatusAtom, {
      status,
      checks,
      lastCheck: Date.now(),
    });

    // アラート送信
    if (status !== 'healthy') {
      await sendAlert({
        level:
          status === 'degraded' ? 'warning' : 'critical',
        message: `Application health status: ${status}`,
        details: checks,
      });
    }
  }
);

async function checkDatabase(): Promise<boolean> {
  try {
    const response = await fetch('/api/health/database');
    return response.ok;
  } catch {
    return false;
  }
}

async function checkAPI(): Promise<boolean> {
  try {
    const response = await fetch('/api/health/ping');
    return response.ok;
  } catch {
    return false;
  }
}

function checkMemoryUsage(): boolean {
  if ('memory' in performance) {
    const memInfo = (performance as any).memory;
    const usage =
      memInfo.usedJSHeapSize / memInfo.totalJSHeapSize;
    return usage < 0.9; // 90%未満であれば正常
  }
  return true;
}

function checkAtomStore(): boolean {
  // Jotai のストア状態をチェック
  try {
    // 基本的な atom の動作確認
    const testAtom = atom(0);
    return true;
  } catch {
    return false;
  }
}

まとめ

Next.js と Jotai の組み合わせは、モダン Web アプリケーション開発において革新的なソリューションを提供します。本記事では、SSR 対応のアプリケーション構築における実践的なアプローチを包括的にご紹介いたしました。

主要なメリットの再確認

開発体験の向上

  • Atomic な状態管理による直感的なコード記述
  • TypeScript との完全な統合による型安全性
  • コンポーネントレベルでの細かな最適化制御

パフォーマンスの最適化

  • 選択的ハイドレーションによる初期ロード時間の短縮
  • 段階的データ読み込みによるユーザー体験の向上
  • メモリ効率的なキャッシュ戦略

運用面での優位性

  • 包括的なエラー監視とログ収集システム
  • CI/CD パイプラインとの完全な統合
  • プロダクション環境での安定した動作

今後の発展可能性

Jotai の ecosystem は急速に成長しており、今後も新しい機能や最適化手法が登場することが期待されます。特に、React の concurrent features との統合や、Suspense の進化に合わせた新しいパターンの登場が注目されています。

開発者の皆様には、本記事の内容を参考に、ぜひ実際のプロジェクトで Next.js と Jotai の組み合わせを試していただければと思います。この革新的なアプローチが、より良い Web アプリケーション開発の一助となることを願っております。

関連リンク