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