State Management
This document explains the state management approach used in the Rhesis frontend application.
State Management Architecture
The Rhesis frontend uses a hybrid state management approach:
- Server Components: Data fetching and state management on the server
- React Context: For global UI state and shared state across components
- Local Component State: For component-specific state
- Server Actions: For mutations and form submissions
- URL State: For preserving state in the URL (e.g., filters, pagination)
This approach minimizes client-side state management while leveraging Next.js App Router features.
Server Components
Server components handle data fetching and initial state:
// app/(protected)/projects/page.tsx
import { projectsApi } from '@/utils/api-client/endpoints/projects';
import ProjectList from '@/components/projects/ProjectList';
export default async function ProjectsPage({
searchParams,
}: {
searchParams: { page?: string; limit?: string; search?: string };
}) {
// Parse query parameters
const page = parseInt(searchParams.page || '1', 10);
const limit = parseInt(searchParams.limit || '10', 10);
const search = searchParams.search || '';
// Fetch data on the server
const { data: projects, total } = await projectsApi.getAll({
skip: (page - 1) * limit,
limit,
search,
});
return (
<div>
<h1>Projects ({total})</h1>
<ProjectList
projects={projects}
total={total}
page={page}
limit={limit}
search={search}
/>
</div>
);
}
React Context
For global state, the application uses React Context:
Context Definition
// src/components/providers/ThemeProvider.tsx
'use client';
import { createContext, useContext, useState, useEffect } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
// Initialize theme from localStorage
useEffect(() => {
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
}
}, []);
// Update localStorage when theme changes
useEffect(() => {
localStorage.setItem('theme', theme);
// Apply theme to document
const isDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Context Usage
// src/components/layout/ThemeToggle.tsx
'use client';
import { useTheme } from '@/components/providers/ThemeProvider';
export default function ThemeToggle() {
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
return (
<button onClick={toggleTheme}>
{theme === 'light' && 'Light Mode'}
{theme === 'dark' && 'Dark Mode'}
{theme === 'system' && 'System Theme'}
</button>
);
}
Context Setup
// app/layout.tsx
import { ThemeProvider } from '@/components/providers/ThemeProvider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Local Component State
For component-specific state, use React’s built-in hooks:
// src/components/projects/ProjectFilter.tsx
'use client';
import { useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
export default function ProjectFilter({
initialSearch = '',
}: {
initialSearch?: string;
}) {
const [search, setSearch] = useState(initialSearch);
const router = useRouter();
const pathname = usePathname();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Update URL with search parameter
const params = new URLSearchParams();
if (search) {
params.set('search', search);
}
router.push(`${pathname}?${params.toString()}`);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects..."
/>
<button type="submit">Search</button>
</form>
);
}
Server Actions
For mutations and form submissions, use server actions:
// app/(protected)/projects/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { projectsApi } from '@/utils/api-client/endpoints/projects';
import type { ProjectCreate } from '@/utils/api-client/types/projects';
export async function createProject(formData: FormData) {
const name = formData.get('name') as string;
const description = formData.get('description') as string;
if (!name) {
return { success: false, error: 'Name is required' };
}
try {
await projectsApi.create({
name,
description: description || undefined,
});
revalidatePath('/projects');
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to create project'
};
}
}
Using server actions in a form:
// src/components/projects/ProjectForm.tsx
'use client';
import { useFormState } from 'react-dom';
import { createProject } from '@/app/(protected)/projects/actions';
const initialState = { success: false, error: null };
export default function ProjectForm() {
const [state, formAction] = useFormState(createProject, initialState);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
</div>
<div>
<label htmlFor="description">Description</label>
<textarea id="description" name="description" />
</div>
{state.error && <div className="error">{state.error}</div>}
<button type="submit">Create Project</button>
</form>
);
}
URL State
For preserving state in the URL:
// src/components/common/Pagination.tsx
'use client';
import Link from 'next/link';
import { usePathname, useSearchParams } from 'next/navigation';
interface PaginationProps {
total: number;
page: number;
limit: number;
}
export default function Pagination({ total, page, limit }: PaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const totalPages = Math.ceil(total / limit);
const createPageURL = (pageNumber: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
<div className="pagination">
{page > 1 && (
<Link href={createPageURL(page - 1)}>Previous</Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNumber) => (
<Link
key={pageNumber}
href={createPageURL(pageNumber)}
className={pageNumber === page ? 'active' : ''}
>
{pageNumber}
</Link>
))}
{page < totalPages && (
<Link href={createPageURL(page + 1)}>Next</Link>
)}
</div>
);
}
State Management Best Practices
- Minimize Client-Side State: Use server components for data fetching where possible
- Colocate State: Keep state as close as possible to where it’s used
- Use URL for Shareable State: Store filters, pagination, and other shareable state in the URL
- Prefer Server Actions: Use server actions for mutations instead of client-side API calls
- Context for Global State: Use React Context for truly global state like theme, user preferences
- Avoid Prop Drilling: Use context or composition to avoid excessive prop drilling
- Revalidate After Mutations: Use
revalidatePath
to refresh data after mutations
Example: Complex State Management
For complex state management needs, combine these approaches:
// app/(protected)/projects/[id]/page.tsx
import { projectsApi } from '@/utils/api-client/endpoints/projects';
import ProjectDetails from '@/components/projects/ProjectDetails';
import { notFound } from 'next/navigation';
export default async function ProjectPage({
params,
}: {
params: { id: string };
}) {
try {
// Fetch data on the server
const project = await projectsApi.getById(params.id);
return <ProjectDetails project={project} />;
} catch (error) {
// Handle 404 errors
notFound();
}
}
// src/components/projects/ProjectDetails.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { updateProject, deleteProject } from '@/app/(protected)/projects/actions';
import type { Project } from '@/utils/api-client/types/projects';
export default function ProjectDetails({ project }: { project: Project }) {
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description);
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const handleUpdate = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const result = await updateProject(project.id, {
name,
description,
});
if (result.success) {
setIsEditing(false);
} else {
setError(result.error);
}
};
const handleDelete = async () => {
if (!window.confirm('Are you sure you want to delete this project?')) {
return;
}
setIsDeleting(true);
const result = await deleteProject(project.id);
if (result.success) {
router.push('/projects');
} else {
setError(result.error);
setIsDeleting(false);
}
};
if (isEditing) {
return (
<form onSubmit={handleUpdate}>
{/* Edit form fields */}
{error && <div className="error">{error}</div>}
<div className="actions">
<button type="submit">Save</button>
<button type="button" onClick={() => setIsEditing(false)}>
Cancel
</button>
</div>
</form>
);
}
return (
<div>
<h1>{project.name}</h1>
<p>{project.description}</p>
<div className="actions">
<button onClick={() => setIsEditing(true)}>Edit</button>
<button
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
);
}