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 アプリケーション開発の一助となることを願っております。
関連リンク
- article
Next.js × Jotai で作る SSR 対応のモダン Web アプリケーション
- article
Next.js 14時代のESLint徹底解説:Server/Client ComponentsとApp Router対応の最適ルール設定
- article
Next.js での Zustand 活用法:App Router 時代のステート設計
- article
Next.jsの開発中に発生する Warning: Prop `className` did not match. Server: Client: ...の解決策
- article
Next.jsとEdge Runtimeを組み合わせて超爆速サーバーレス表示を実現する方法
- article
Next.jsでSEO対策を強化するための5つのポイント + アンチパターンの回避策
- review
アジャイル初心者でも大丈夫!『アジャイルサムライ − 達人開発者への道』Jonathan Rasmusson
- review
人生が作品になる!『自分の中に毒を持て』岡本太郎
- review
体調不良の 99%が解決!『眠れなくなるほど面白い 図解 自律神経の話』小林弘幸著で学ぶ、現代人必須の自律神経コントロール術と人生を変える健康革命
- review
衝撃の事実!『睡眠こそ最強の解決策である』マシュー・ウォーカー著が明かす、99%の人が知らない睡眠の驚くべき真実と人生を変える科学的メカニズム
- review
人生が激変!『嫌われる勇気』岸見一郎・古賀史健著から学ぶ、アドラー心理学で手に入れる真の幸福と自己実現
- review
もう無駄な努力はしない!『イシューからはじめよ』安宅和人著で身につけた、99%の人が知らない本当に価値ある問題の見つけ方