Skip to Content

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:

  1. Server Components: Data fetching and state management on the server
  2. React Context: For global UI state and shared state across components
  3. Local Component State: For component-specific state
  4. Server Actions: For mutations and form submissions
  5. 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

  1. Minimize Client-Side State: Use server components for data fetching where possible
  2. Colocate State: Keep state as close as possible to where it’s used
  3. Use URL for Shareable State: Store filters, pagination, and other shareable state in the URL
  4. Prefer Server Actions: Use server actions for mutations instead of client-side API calls
  5. Context for Global State: Use React Context for truly global state like theme, user preferences
  6. Avoid Prop Drilling: Use context or composition to avoid excessive prop drilling
  7. 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> ); }