Skip to main content

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​