Contexts and State Management
Overview
Scoriet uses React Context API for global state management, avoiding prop drilling and providing centralized access to application state. Three main contexts handle authentication, theming, projects, and notifications.
Context Architecture
App
├── ThemeProvider
│ ├── colors
│ ├── isDarkMode
│ └── setIsDarkMode()
├── ToastProvider
│ ├── showSuccess()
│ ├── showError()
│ └── showInfo()
└── ProjectProvider
├── projects[]
├── selectedProject
├── setSelectedProject()
├── editLock
├── acquireLock()
└── releaseLock()
ThemeContext
Manages application theme and colors globally.
Location
resources/js/contexts/ThemeContext.tsx
Interface
interface ColorScheme {
primary: string;
secondary: string;
background: string;
surface: string;
border: string;
text: string;
error: string;
warning: string;
success: string;
info: string;
[key: string]: string;
}
interface ThemeContextType {
colors: ColorScheme;
isDarkMode: boolean;
setIsDarkMode: (isDark: boolean) => void;
colorScheme: 'light' | 'dark';
setColorScheme: (scheme: 'light' | 'dark') => void;
}
Usage
import { useTheme } from '@/contexts/ThemeContext';
export function MyComponent() {
const { colors, isDarkMode, setIsDarkMode } = useTheme();
return (
<div style={{ backgroundColor: colors.background }}>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
style={{ color: colors.text }}
>
Toggle Theme
</button>
</div>
);
}
Color Palette
The theme provides a complete color palette:
const lightColors: ColorScheme = {
primary: '#2563eb', // Blue
secondary: '#64748b', // Gray
background: '#ffffff', // White
surface: '#f8fafc', // Light gray
border: '#e2e8f0', // Light border
text: '#1e293b', // Dark text
error: '#ef4444', // Red
warning: '#f59e0b', // Amber
success: '#10b981', // Green
info: '#3b82f6', // Blue
};
const darkColors: ColorScheme = {
primary: '#60a5fa', // Light blue
secondary: '#cbd5e1', // Light gray
background: '#0f172a', // Very dark
surface: '#1e293b', // Dark gray
border: '#334155', // Dark border
text: '#f1f5f9', // Light text
error: '#f87171', // Light red
warning: '#fbbf24', // Light amber
success: '#34d399', // Light green
info: '#60a5fa', // Light blue
};
Storage
Theme preference is persisted to localStorage:
const [isDarkMode, setIsDarkMode] = useState(() => {
const stored = localStorage.getItem('theme_mode');
return stored ? stored === 'dark' : false;
});
useEffect(() => {
localStorage.setItem('theme_mode', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
ToastContext
Provides toast notification API for success, error, and info messages.
Location
resources/js/contexts/ToastContext.tsx
Interface
interface ToastContextType {
showSuccess: (message: string, duration?: number) => void;
showError: (message: string, duration?: number) => void;
showInfo: (message: string, duration?: number) => void;
showWarning: (message: string, duration?: number) => void;
clear: () => void;
}
Usage
import { useToast } from '@/contexts/ToastContext';
export function MyComponent() {
const toast = useToast();
const handleSave = async () => {
try {
await save();
toast.showSuccess('Data saved successfully');
} catch (error) {
toast.showError('Failed to save data');
}
};
return <button onClick={handleSave}>Save</button>;
}
Toast Examples
// Success toast (3000ms default)
toast.showSuccess('Operation completed');
// Error toast
toast.showError('An error occurred', 5000);
// Info toast
toast.showInfo('Please note this information');
// Warning toast
toast.showWarning('This action cannot be undone', 4000);
Implementation
Toast uses PrimeReact's Toast component:
import { Toast } from 'primereact/toast';
export function ToastProvider({ children }) {
const toastRef = useRef(null);
const value = {
showSuccess: (message: string) => {
toastRef.current?.show({
severity: 'success',
summary: 'Success',
detail: message,
life: 3000,
});
},
showError: (message: string) => {
toastRef.current?.show({
severity: 'error',
summary: 'Error',
detail: message,
life: 5000,
});
},
// ... other methods
};
return (
<ToastContext.Provider value={value}>
<Toast ref={toastRef} />
{children}
</ToastContext.Provider>
);
}
ProjectContext
Manages selected project, project list, and collaborative edit locking.
Location
resources/js/contexts/ProjectContext.tsx
Interfaces
interface Project {
id: number;
name: string;
description: string;
owner_id: number;
is_active: boolean;
is_public?: boolean;
join_code?: string;
allow_join_requests?: boolean;
created_at: string;
updated_at: string;
teams_count?: number;
members_count?: number;
templates_count?: number;
databases_count?: number;
applications_count?: number;
is_owner?: boolean;
can_join?: boolean;
default_language?: string;
target_language?: string;
enabled_languages?: string[];
is_soft_locked?: boolean;
subscription?: ProjectSubscription | null;
diagram_max_tables_per_row?: number;
diagram_table_width?: number;
form_designer_snap_to_grid?: boolean;
form_designer_grid_size?: number;
report_designer_snap_to_grid?: boolean;
report_designer_grid_unit?: string;
database_type?: string;
database_server?: string;
owner: {
id: number;
name: string;
email: string;
};
}
interface ProjectSubscription {
id: number;
expires_at: string | null;
is_expired: boolean;
is_soft_locked: boolean;
days_remaining: number | null;
}
interface EditLockState {
isLocked: boolean;
lockedByUserId: number | null;
lockedByUserName: string | null;
isLockedByMe: boolean;
}
interface ProjectContextType {
projects: Project[];
selectedProject: Project | null;
setSelectedProject: (project: Project | null) => void;
loadProjects: () => Promise<void>;
loading: boolean;
clearSavedProject: () => void;
setPreferredProject: (project: Project | null) => void;
editLock: EditLockState | null;
acquireLock: (projectId: number) => Promise<boolean>;
releaseLock: (projectId: number) => Promise<void>;
}
Usage
import { useProject } from '@/contexts/ProjectContext';
export function MyComponent() {
const {
projects,
selectedProject,
setSelectedProject,
loadProjects,
editLock,
acquireLock,
releaseLock,
} = useProject();
useEffect(() => {
loadProjects();
}, []);
const handleProjectChange = async (project: Project) => {
setSelectedProject(project);
// Acquire exclusive edit lock
const locked = await acquireLock(project.id);
if (locked) {
console.log('Lock acquired, editing allowed');
}
};
return (
<>
<select onChange={(e) => {
const project = projects.find(p => p.id === parseInt(e.target.value));
if (project) handleProjectChange(project);
}}>
{projects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{selectedProject && (
<>
<h1>{selectedProject.name}</h1>
{editLock?.isLockedByMe ? (
<span>You have edit access</span>
) : editLock?.isLocked ? (
<span>Locked by {editLock.lockedByUserName}</span>
) : null}
</>
)}
</>
);
}
Project Loading
Projects are loaded from the API on provider initialization:
const loadProjects = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('access_token');
const response = await fetch('/api/projects', {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
setProjects(data.projects);
}
} catch (error) {
console.error('Failed to load projects:', error);
} finally {
setLoading(false);
}
}, []);
Edit Locking (Real-time Collaboration)
Edit locks prevent concurrent editing by multiple users:
const acquireLock = useCallback(async (projectId: number) => {
try {
const token = localStorage.getItem('access_token');
const response = await fetch(`/api/projects/${projectId}/lock`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const lockData = await response.json();
setEditLock({
isLocked: true,
lockedByUserId: lockData.user_id,
lockedByUserName: lockData.user_name,
isLockedByMe: lockData.is_mine,
});
return lockData.is_mine;
}
} catch (error) {
console.error('Failed to acquire lock:', error);
return false;
}
}, []);
const releaseLock = useCallback(async (projectId: number) => {
try {
const token = localStorage.getItem('access_token');
await fetch(`/api/projects/${projectId}/lock`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
setEditLock(null);
} catch (error) {
console.error('Failed to release lock:', error);
}
}, []);
Lock Heartbeat
The lock is maintained with periodic heartbeat requests:
useEffect(() => {
if (selectedProject && editLock?.isLockedByMe) {
// Send heartbeat every 30 seconds
const heartbeat = setInterval(() => {
fetch(`/api/projects/${selectedProject.id}/lock/heartbeat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
});
}, 30000);
return () => clearInterval(heartbeat);
}
}, [selectedProject, editLock]);
WebSocket Subscription
The context subscribes to real-time lock changes via Laravel Echo:
useEffect(() => {
if (selectedProject) {
const channel = getEcho().channel(`project.${selectedProject.id}`);
channel.listen('ProjectLockAcquired', (data) => {
setEditLock({
isLocked: true,
lockedByUserId: data.user_id,
lockedByUserName: data.user_name,
isLockedByMe: false,
});
});
channel.listen('ProjectLockReleased', () => {
setEditLock(null);
});
return () => {
channel.stopListening('ProjectLockAcquired');
channel.stopListening('ProjectLockReleased');
getEcho().leaveChannel(`project.${selectedProject.id}`);
};
}
}, [selectedProject]);
Project Selection Persistence
Selected project is saved to localStorage:
const setSelectedProject = (project: Project | null) => {
setSelectedProjectState(project);
if (project) {
localStorage.setItem('selectedProjectId', project.id.toString());
} else {
localStorage.removeItem('selectedProjectId');
}
};
// Restore on mount
useEffect(() => {
const savedProjectId = localStorage.getItem('selectedProjectId');
if (savedProjectId && projects.length > 0) {
const project = projects.find(p => p.id === parseInt(savedProjectId));
if (project) setSelectedProject(project);
}
}, [projects]);
Creating Custom Contexts
Step 1: Define Interface
interface MyContextType {
value: string;
setValue: (value: string) => void;
}
Step 2: Create Context
const MyContext = createContext<MyContextType | undefined>(undefined);
Step 3: Create Provider Component
export function MyProvider({ children }: { children: ReactNode }) {
const [value, setValue] = useState('');
return (
<MyContext.Provider value={{ value, setValue }}>
{children}
</MyContext.Provider>
);
}
Step 4: Create Hook
export function useMyContext() {
const context = useContext(MyContext);
if (!context) {
throw new Error('useMyContext must be used within MyProvider');
}
return context;
}
Step 5: Use in App
<MyProvider>
<App />
</MyProvider>
State Management Best Practices
1. Use Context for Global State
// Good: Global state in context
const { theme } = useTheme();
// Bad: Prop drilling
<Component theme={theme} />
<NestedComponent theme={theme} />
<DeeplyNestedComponent theme={theme} />
2. Keep Contexts Focused
// Good: One context per concern
<ThemeProvider>
<ToastProvider>
<ProjectProvider>
<App />
</ProjectProvider>
</ToastProvider>
</ThemeProvider>
// Bad: Monolithic mega-context
<AppProvider> // Everything
<App />
</AppProvider>
3. Memoize Context Values
// Good: Stable reference prevents re-renders
const value = useMemo(() => ({
theme,
setTheme,
colors,
}), [theme, setTheme, colors]);
return (
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
);
// Bad: New object each render triggers re-renders
return (
<MyContext.Provider value={{ theme, setTheme, colors }}>
{children}
</MyContext.Provider>
);
4. Use localStorage for Persistence
// Good: Persistent state
const [isDarkMode, setIsDarkMode] = useState(() => {
return localStorage.getItem('theme') === 'dark';
});
useEffect(() => {
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
// Bad: Lost on reload
const [isDarkMode, setIsDarkMode] = useState(false);
5. Handle Loading and Error States
interface ProjectContextType {
projects: Project[];
loading: boolean;
error: string | null;
loadProjects: () => Promise<void>;
}
// Usage
const { projects, loading, error } = useProject();
if (loading) return <ProgressSpinner />;
if (error) return <Message severity="error">{error}</Message>;
return <div>{projects.map(p => p.name)}</div>;
Debugging Contexts
React DevTools Extension
Install React DevTools to inspect context values:
Chrome Web Store → React Developer Tools
Manual Logging
// Log when context value changes
useEffect(() => {
console.log('ProjectContext changed:', { projects, selectedProject, editLock });
}, [projects, selectedProject, editLock]);
Testing Contexts
import { render, screen } from '@testing-library/react';
describe('ProjectContext', () => {
it('should provide selected project', () => {
const mockProject = { id: 1, name: 'Test' };
render(
<ProjectProvider initialProject={mockProject}>
<TestComponent />
</ProjectProvider>
);
expect(screen.getByText('Test')).toBeInTheDocument();
});
});
Common Patterns
Load Data on Project Change
const { selectedProject } = useProject();
useEffect(() => {
if (selectedProject) {
loadDataForProject(selectedProject.id);
}
}, [selectedProject?.id]);
Show Loading State
const { selectedProject, editLock } = useProject();
if (!selectedProject) {
return <Message>No project selected</Message>;
}
if (!editLock?.isLockedByMe) {
return <Message severity="warn">Project is locked</Message>;
}
return <EditorComponent />;
Handle Errors
const toast = useToast();
const { selectedProject } = useProject();
const handleSave = async () => {
try {
if (!selectedProject) {
throw new Error('No project selected');
}
await save();
toast.showSuccess('Saved');
} catch (error) {
toast.showError(error.message);
}
};
Next Steps
- Components Library - Reusable UI components
- Panels Architecture - Panel system
- Overview - Architecture overview