State Management
This document describes the state management patterns used in the Frontend service, including React Context, custom hooks, and TanStack Query integration.
State Categories
The application uses different strategies for different types of state:
graph TB
subgraph "State Types"
Auth[Authentication State]
Server[Server State]
UI[UI State]
Form[Form State]
end
subgraph "Solutions"
Context[React Context]
TQ[TanStack Query]
Local[useState/useReducer]
RHF[React Hook Form]
end
Auth --> Context
Server --> TQ
UI --> Local
Form --> RHF
| State Type | Tool | Examples | Persistence |
|---|---|---|---|
| Authentication | React Context | User, token | localStorage |
| Server Data | TanStack Query | Sessions, messages, users | Cache (memory) |
| UI State | useState | Modals, tabs, selections | None |
| Form State | React Hook Form | Input values, validation | None |
Authentication Context
The AuthContext provides global authentication state across the application.
Implementation
// src/context/AuthContext.tsx
interface User {
username: string;
email: string;
role: "student" | "professor" | "admin";
subjects: string[];
}
interface AuthContextType {
user: User | null;
token: string | null;
login: (token: string, user: User) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
// Initialize from localStorage on mount
useEffect(() => {
const storedToken = localStorage.getItem("token");
const storedUser = localStorage.getItem("user");
if (storedToken && storedUser) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
}
}, []);
const login = (newToken: string, newUser: User) => {
localStorage.setItem("token", newToken);
localStorage.setItem("user", JSON.stringify(newUser));
setToken(newToken);
setUser(newUser);
};
const logout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider
value=
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
Usage
// In components
function Header() {
const { user, logout, isAuthenticated } = useAuth();
return (
<header>
{isAuthenticated && <span>Welcome, {user.username}</span>}
<button onClick={logout}>Logout</button>
</header>
);
}
// In route guards
function RequireAuth() {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return <Outlet />;
}
Authentication Flow
sequenceDiagram
participant U as User
participant LP as LoginPage
participant API as api.ts
participant AC as AuthContext
participant LS as localStorage
U->>LP: Submit credentials
LP->>API: POST /token
API-->>LP: { access_token }
LP->>API: GET /users/me
API-->>LP: { user data }
LP->>AC: login(token, user)
AC->>LS: Store token & user
AC->>AC: Update state
LP->>LP: Navigate to app
TanStack Query
TanStack Query (React Query) manages all server state with automatic caching, refetching, and synchronization.
Query Client Setup
// src/main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // 30 seconds
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
</AuthProvider>
</QueryClientProvider>
);
Query Keys Convention
Query keys follow a hierarchical pattern:
// Pattern: [domain, resource, ...identifiers]
["sessions"] // All sessions
["sessions", sessionId] // Single session
["sessions", sessionId, "messages"] // Session messages
["professor", "subjects"] // Professor's subjects
["professor", "students", subjectName] // Students in subject
["admin", "users"] // All users
["admin", "users", role] // Users by role
["admin", "subjects"] // All subjects
Custom Hooks
useChat
Manages chat state and messaging for the active session.
// src/hooks/useChat.ts
export function useChat(sessionId: string | null) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isTestMode, setIsTestMode] = useState(false);
// Load history when session changes
useEffect(() => {
if (sessionId) {
loadHistory(sessionId);
} else {
setMessages([]);
}
}, [sessionId]);
const loadHistory = async (id: string) => {
const response = await api.get(`/chat/${id}/history`);
setMessages(convertHistory(response.data.messages));
};
const sendMessage = async (content: string, asignatura: string) => {
if (!sessionId) return;
// Optimistic update
const userMessage: Message = {
id: crypto.randomUUID(),
role: "user",
content,
timestamp: new Date(),
};
setMessages(prev => [...prev, userMessage]);
setIsLoading(true);
try {
const response = await api.post("/chat", {
id: sessionId,
message: content,
asignatura,
});
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: "assistant",
content: response.data.response,
timestamp: new Date(),
};
setMessages(prev => [...prev, assistantMessage]);
// Check if test mode started
if (response.data.is_test) {
setIsTestMode(true);
}
} catch (error) {
// Remove optimistic message on error
setMessages(prev => prev.filter(m => m.id !== userMessage.id));
throw error;
} finally {
setIsLoading(false);
}
};
const resumeTest = async (answer: string) => {
if (!sessionId) return;
setIsLoading(true);
try {
const response = await api.post("/resume_chat", {
id: sessionId,
answer,
});
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: "assistant",
content: response.data.response,
timestamp: new Date(),
};
setMessages(prev => [...prev, assistantMessage]);
// Check if test ended
if (!response.data.is_test) {
setIsTestMode(false);
}
} finally {
setIsLoading(false);
}
};
return {
messages,
sendMessage,
resumeTest,
isLoading,
isTestMode,
clearMessages: () => setMessages([]),
};
}
useSessions
Manages chat session CRUD operations.
// src/hooks/useSessions.ts
export function useSessions() {
return useQuery({
queryKey: ["sessions"],
queryFn: async () => {
const response = await api.get("/sessions");
return response.data;
},
});
}
export function useCreateSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (asignatura: string) => {
const response = await api.post("/sessions", { asignatura });
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
export function useDeleteSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (sessionId: string) => {
await api.delete(`/sessions/${sessionId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
export function useRenameSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ sessionId, title }: { sessionId: string; title: string }) => {
const response = await api.patch(`/sessions/${sessionId}`, { title });
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
});
}
useDashboard
Professor dashboard data hooks.
// src/hooks/useDashboard.ts
export function useSubjects() {
return useQuery({
queryKey: ["professor", "subjects"],
queryFn: async () => {
const response = await api.get("/professor/subjects");
return response.data;
},
});
}
export function useStudents(subject: string) {
return useQuery({
queryKey: ["professor", "students", subject],
queryFn: async () => {
const response = await api.get(`/professor/students/${subject}`);
return response.data;
},
enabled: !!subject,
});
}
export function useDocuments(subject: string) {
return useQuery({
queryKey: ["professor", "documents", subject],
queryFn: async () => {
const response = await api.get(`/professor/subjects/${subject}/documents`);
return response.data;
},
enabled: !!subject,
});
}
export function useUploadDocument() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
subject,
file,
tipoDocumento,
autoIndex,
}: {
subject: string;
file: File;
tipoDocumento: string;
autoIndex: boolean;
}) => {
const formData = new FormData();
formData.append("file", file);
formData.append("tipo_documento", tipoDocumento);
formData.append("auto_index", String(autoIndex));
const response = await api.post(
`/professor/subjects/${subject}/documents`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } }
);
return response.data;
},
onSuccess: (_, { subject }) => {
queryClient.invalidateQueries({ queryKey: ["professor", "documents", subject] });
},
});
}
export function useStudentProgress(subject: string) {
return useQuery({
queryKey: ["professor", "progress", subject],
queryFn: async () => {
const response = await api.get(`/professor/subjects/${subject}/progress`);
return response.data;
},
enabled: !!subject,
});
}
useAdmin
Admin panel hooks.
// src/hooks/useAdmin.ts
export function useAdminStats() {
return useQuery<AdminStats>({
queryKey: ["admin", "stats"],
queryFn: async () => {
const response = await api.get("/admin/stats");
return response.data;
},
});
}
export function useUsers(role?: "student" | "professor" | "admin") {
return useQuery<UserInfo[]>({
queryKey: ["admin", "users", role],
queryFn: async () => {
const params = role ? { role } : {};
const response = await api.get("/admin/users", { params });
return response.data;
},
});
}
export function useUserSearch(query: string, role?: string) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300);
return () => clearTimeout(timer);
}, [query]);
return useQuery<UserInfo[]>({
queryKey: ["admin", "users", "search", debouncedQuery, role],
queryFn: async () => {
if (debouncedQuery.length < 2) return [];
const params: Record<string, string> = { q: debouncedQuery };
if (role) params.role = role;
const response = await api.get("/admin/users/search", { params });
return response.data;
},
enabled: debouncedQuery.length >= 2,
});
}
export function useEnrollStudent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: EnrollRequest) => {
const response = await api.post("/admin/enroll", data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
queryClient.invalidateQueries({ queryKey: ["professor", "subjects"] });
},
});
}
export function usePromoteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: PromoteRequest) => {
const response = await api.post("/admin/promote", data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
queryClient.invalidateQueries({ queryKey: ["admin", "stats"] });
},
});
}
export function useSubjects() {
return useQuery<{ subjects: SubjectInfo[]; total: number }>({
queryKey: ["admin", "subjects"],
queryFn: async () => {
const response = await api.get("/admin/subjects");
return response.data;
},
});
}
export function useCreateSubject() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: CreateSubjectRequest) => {
const response = await api.post<SubjectInfo>("/admin/subjects", data);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "subjects"] });
queryClient.invalidateQueries({ queryKey: ["admin", "stats"] });
},
});
}
useUserPreferences
User settings and preferences.
// src/hooks/useUserPreferences.ts
export function useUserPreferences() {
return useQuery<UserPreferences>({
queryKey: ["preferences"],
queryFn: async () => {
const response = await api.get("/users/me/preferences");
return response.data;
},
});
}
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (preferences: UserPreferences) => {
const response = await api.put("/users/me/preferences", preferences);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["preferences"] });
},
});
}
Hooks Dependency Map
graph TB
subgraph "Pages"
CP[ChatPage]
DP[DashboardPage]
AP[AdminDashboard]
SP[SettingsPage]
end
subgraph "Custom Hooks"
UC[useChat]
US[useSessions]
UD[useDashboard hooks]
UA[useAdmin hooks]
UP[useUserPreferences]
end
subgraph "Core"
Auth[useAuth]
TQ[TanStack Query]
API[api.ts]
end
CP --> UC
CP --> US
DP --> UD
AP --> UA
SP --> UP
SP --> Auth
UC --> API
US --> TQ
UD --> TQ
UA --> TQ
UP --> TQ
TQ --> API
Auth --> API
Form State with React Hook Form
Forms use React Hook Form with Zod validation.
Example: Login Form
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
function LoginPage() {
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
username: "",
password: "",
},
});
const onSubmit = async (data: LoginFormValues) => {
// Submit logic
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ... */}
</form>
</Form>
);
}
Example: Create Subject Form
const createSubjectSchema = z.object({
name: z.string()
.min(1, "Name is required")
.regex(/^[a-z0-9-]+$/, "Use lowercase letters, numbers, and hyphens"),
display_name: z.string().min(1, "Display name is required"),
guia_url: z.string().url().optional().or(z.literal("")),
});
type CreateSubjectValues = z.infer<typeof createSubjectSchema>;
Cache Invalidation Strategy
graph TB
subgraph "Mutation"
M[useMutation]
end
subgraph "Success Callbacks"
IC[invalidateQueries]
end
subgraph "Query Cache"
Q1["['sessions']"]
Q2["['professor', 'subjects']"]
Q3["['admin', 'users']"]
end
M -->|onSuccess| IC
IC -->|invalidates| Q1
IC -->|invalidates| Q2
IC -->|invalidates| Q3
Invalidation Patterns
| Action | Invalidated Queries |
|---|---|
| Create session | ['sessions'] |
| Delete session | ['sessions'] |
| Upload document | ['professor', 'documents', subject] |
| Enroll student | ['admin', 'users'], ['professor', 'subjects'] |
| Promote user | ['admin', 'users'], ['admin', 'stats'] |
| Create subject | ['admin', 'subjects'], ['admin', 'stats'] |
Error Handling in State
API Error Utility
// src/lib/errors.ts
export function getErrorMessage(error: unknown, fallback: string): string {
if (isApiError(error)) {
return error.response?.data?.detail ||
error.response?.data?.message ||
fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
Toast Notifications
import { toast } from "sonner";
// In mutation handlers
const createSession = useCreateSession();
const handleCreate = async () => {
try {
await createSession.mutateAsync(subject);
toast.success("Session created");
} catch (error) {
toast.error(getErrorMessage(error, "Failed to create session"));
}
};
Related Documentation
- Architecture - Application structure
- Components - Component documentation
- Configuration - Environment setup