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
revalidatePathto 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>
);
}