31 KiB
Frontend Context Module
Overview
The Context module provides React Context-based state management for cross-cutting concerns that need to be shared across the component tree without prop drilling. Currently, the module contains a single context for managing download/preload progress in the PWA offline system.
Location: frontend/src/context/
Total Files: 1 file (~256 LOC)
Architecture
frontend/src/context/
└── DownloadContext.tsx (256 LOC) # Download progress state management
Related Files:
├── lib/offline/DownloadEventBus.ts (180 LOC) # Event emitter for download events
├── hooks/usePreloadProgress.ts (221 LOC) # Hook-based alternative
├── config/offline.config.ts (53 LOC) # Event names, IndexedDB, cache settings
├── config/preload.config.ts (95 LOC) # Priority weights, auto-cleanup timeouts
├── types/offline.ts (195 LOC) # Type definitions
│
└── components/Offline/
├── DownloadProgressPanel.tsx (194 LOC) # Progress UI with job list
├── OfflineStatusIndicator.tsx (62 LOC) # Status badge (online/offline/syncing)
├── OfflineToggle.tsx (150 LOC) # Download toggle with storage checks
└── StorageUsageDisplay.tsx (125 LOC) # Storage quota UI with persistence
DownloadContext (DownloadContext.tsx)
Purpose: Manage download job state and progress for PWA asset preloading.
Lines: 256 LOC
Context State Interface
interface DownloadState {
jobs: PreloadJob[]; // All tracked download jobs
activeCount: number; // Jobs currently downloading/queued
completedCount: number; // Jobs completed successfully
errorCount: number; // Jobs that failed
totalProgress: number; // Overall progress percentage (0-100)
isDownloading: boolean; // True if any job is active
}
Context Value Interface
interface DownloadContextValue extends DownloadState {
clearJob: (id: string) => void; // Remove a specific job
clearAllCompleted: () => void; // Clear all completed jobs
clearAllErrors: () => void; // Clear all errored jobs
clearAll: () => void; // Clear all jobs
}
PreloadJob Interface
interface PreloadJob {
id: string; // Unique job identifier
assetId: string; // Asset being downloaded
url: string; // Download URL
filename: string; // Display filename
progress: number; // 0-100 percent
status: PreloadJobStatus; // 'queued' | 'downloading' | 'completed' | 'error' | 'paused'
bytesLoaded: number; // Bytes downloaded
totalBytes: number; // Total file size
pageId?: string; // Associated tour page
variantType?: AssetVariantType; // thumbnail, preview, webp, etc.
assetType?: AssetType; // image, video, audio, transition
error?: string; // Error message if failed
addedAt: number; // Timestamp added to queue
startedAt?: number; // Timestamp download started
completedAt?: number; // Timestamp completed
}
Provider Implementation
DownloadProvider Component
export function DownloadProvider({ children }: DownloadProviderProps) {
const [jobs, setJobs] = useState<PreloadJob[]>([]);
// Subscribe to DownloadEventBus events
useEffect(() => {
const handleStart = (data: PreloadStartEvent) => {
// Create new job from event data
};
return downloadEventBus.on(OFFLINE_CONFIG.events.preloadStart, handleStart);
}, []);
// Similar subscriptions for progress, complete, error events...
// Computed values with useMemo
const value = useMemo<DownloadContextValue>(() => ({
jobs,
activeCount: jobs.filter(j => j.status === 'downloading' || j.status === 'queued').length,
completedCount: jobs.filter(j => j.status === 'completed').length,
errorCount: jobs.filter(j => j.status === 'error').length,
totalProgress: calculateTotalProgress(jobs),
isDownloading: activeCount > 0,
clearJob,
clearAllCompleted,
clearAllErrors,
clearAll,
}), [jobs, clearJob, clearAllCompleted, clearAllErrors, clearAll]);
return (
<DownloadContext.Provider value={value}>
{children}
</DownloadContext.Provider>
);
}
Event Handling
The provider subscribes to four events from DownloadEventBus:
┌─────────────────────────────────────────────────────────────┐
│ Event Flow │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ usePreloadOrchestrator │
│ DownloadManager │
│ (Emit events via downloadEventBus) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ preloadStart │ │preloadProgress│ │preloadComplete│
│ │ │ │ │ │
│ Create new │ │ Update │ │ Mark complete │
│ job entry │ │ progress % │ │ Auto-remove │
│ │ │ bytesLoaded │ │ after 3s │
└───────────────┘ └───────────────┘ └───────────────┘
│ │ │
└─────────────────────┼─────────────────────┘
▼
┌─────────────────────────────────────────────────────────────┐
│ preloadError │
│ │
│ Mark job as error, store error message │
│ Auto-remove after 10s │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ DownloadContext State │
│ │
│ jobs[], activeCount, completedCount, errorCount, │
│ totalProgress, isDownloading │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Consumer Components │
│ │
│ DownloadProgressPanel, OfflineStatusIndicator, etc. │
└─────────────────────────────────────────────────────────────┘
Event Handlers
// Handle preload start - create new job
const handleStart = (data: PreloadStartEvent) => {
const newJob: PreloadJob = {
id: data.jobId,
assetId: data.assetId,
url: data.url,
filename: data.url.split('/').pop() || 'unknown',
progress: 0,
status: 'downloading',
bytesLoaded: 0,
totalBytes: 0,
addedAt: Date.now(),
startedAt: Date.now(),
};
setJobs((prev) => {
// Avoid duplicates
if (prev.some((j) => j.id === data.jobId)) {
return prev;
}
return [...prev, newJob];
});
};
// Handle preload progress - update existing job
const handleProgress = (data: PreloadProgressEvent) => {
setJobs((prev) =>
updateJobInArray(prev, data.jobId, {
progress: data.progress,
bytesLoaded: data.bytesLoaded,
totalBytes: data.totalBytes,
status: 'downloading',
}),
);
};
// Handle preload complete - mark done and auto-remove
const handleComplete = (data: PreloadCompleteEvent) => {
setJobs((prev) =>
updateJobInArray(prev, data.jobId, {
status: 'completed',
progress: 100,
completedAt: Date.now(),
}),
);
// Auto-remove after 3 seconds
setTimeout(() => {
setJobs((prev) => prev.filter((j) => j.id !== data.jobId));
}, PRELOAD_CONFIG.autoRemove.completedMs);
};
// Handle preload error - mark failed and auto-remove
const handleError = (data: PreloadErrorEvent) => {
setJobs((prev) =>
updateJobInArray(prev, data.jobId, {
status: 'error',
error: data.error,
}),
);
// Auto-remove after 10 seconds
setTimeout(() => {
setJobs((prev) => prev.filter((j) => j.id !== data.jobId));
}, PRELOAD_CONFIG.autoRemove.errorMs);
};
Consumer Hooks
useDownloadContext (Required)
export function useDownloadContext(): DownloadContextValue {
const context = useContext(DownloadContext);
if (!context) {
throw new Error(
'useDownloadContext must be used within a DownloadProvider',
);
}
return context;
}
Usage:
import { useDownloadContext } from '@/context/DownloadContext';
function MyComponent() {
const {
jobs,
activeCount,
completedCount,
totalProgress,
isDownloading,
clearJob,
clearAllCompleted,
} = useDownloadContext();
return (
<div>
{isDownloading && <span>Downloading... {totalProgress}%</span>}
<ul>
{jobs.map(job => (
<li key={job.id}>
{job.filename}: {job.progress}%
<button onClick={() => clearJob(job.id)}>×</button>
</li>
))}
</ul>
</div>
);
}
useDownloadContextOptional (Safe)
export function useDownloadContextOptional(): DownloadContextValue | null {
return useContext(DownloadContext);
}
Usage: For components that may render outside the provider:
function SafeComponent() {
const downloadCtx = useDownloadContextOptional();
// Works even outside DownloadProvider
if (downloadCtx?.isDownloading) {
return <span>Downloading...</span>;
}
return null;
}
Provider Setup
The provider is mounted at the application root in _app.tsx:
// pages/_app.tsx
import { DownloadProvider } from '../context/DownloadContext';
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout || ((page) => page);
return (
<Provider store={store}>
<DownloadProvider>
{getLayout(
<>
<Head>
{/* Meta tags, favicon, PWA manifest */}
</Head>
<ErrorBoundary>
<Component {...pageProps} />
</ErrorBoundary>
<IntroGuide steps={steps} stepsEnabled={stepsEnabled} onExit={handleExit} />
</>
)}
</DownloadProvider>
</Provider>
);
}
export default appWithTranslation(MyApp);
Provider Hierarchy
<Provider store={store}> ← Redux
<DownloadProvider> ← React Context (download progress)
{getLayout( ← Per-page layout (LayoutAuthenticated/LayoutGuest)
<>
<Head /> ← Meta tags, PWA config
<ErrorBoundary>
<Component /> ← Page component
</ErrorBoundary>
<IntroGuide /> ← Onboarding tour
</>
)}
</DownloadProvider>
</Provider>
Related: DownloadEventBus
The context subscribes to events from DownloadEventBus, a browser-native event emitter:
// lib/offline/DownloadEventBus.ts
class DownloadEventBusClass {
private listeners: Map<string, Set<EventCallback<unknown>>> = new Map();
on<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void;
off<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): void;
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void;
once<K extends keyof EventMap>(event: K, callback: EventCallback<EventMap[K]>): () => void;
removeAllListeners(event?: keyof EventMap): void;
listenerCount(event: keyof EventMap): number;
// Convenience methods
emitPreloadStart(data: PreloadStartEvent): void;
emitPreloadProgress(data: PreloadProgressEvent): void;
emitPreloadComplete(data: PreloadCompleteEvent): void;
emitPreloadError(data: PreloadErrorEvent): void;
emitProjectProgress(data: ProjectDownloadProgressEvent): void;
emitProjectComplete(data: ProjectDownloadCompleteEvent): void;
emitQueueUpdate(): void;
}
export const downloadEventBus = new DownloadEventBusClass();
Event Types
// Defined in config/offline.config.ts
export const OFFLINE_CONFIG = {
events: {
preloadStart: 'asset-preload-start',
preloadProgress: 'asset-preload-progress',
preloadComplete: 'asset-preload-complete',
preloadError: 'asset-preload-error',
projectDownloadProgress: 'project-download-progress',
projectDownloadComplete: 'project-download-complete',
queueUpdate: 'queue-update',
},
// ...
};
Related: usePreloadProgress Hook
An alternative hook-based approach that also subscribes to DownloadEventBus:
// hooks/usePreloadProgress.ts
export function usePreloadProgress(): UsePreloadProgressResult {
const [jobs, setJobs] = useState<PreloadJob[]>([]);
// Same event subscription pattern as DownloadContext
useEffect(() => {
return downloadEventBus.on(
OFFLINE_CONFIG.events.preloadStart,
handleStart,
);
}, []);
// ... similar to DownloadContext
return {
jobs,
activeCount,
completedCount,
errorCount,
totalProgress,
isActive,
clearJob,
clearAllCompleted,
clearAllErrors,
};
}
Context vs Hook Comparison
| Aspect | DownloadContext | usePreloadProgress |
|---|---|---|
| State Sharing | Shared across tree | Per-component instance |
| Deduplication | Single subscription | Multiple subscriptions |
| Use Case | Global UI (headers) | Local UI (panels) |
| Performance | Better for many consumers | Better for single consumer |
| Coupling | Requires Provider | Standalone |
Recommendation: Use DownloadContext for shared global state (status indicators in headers), use usePreloadProgress for isolated components (embedded progress panels).
Consumer Components
DownloadProgressPanel
Shows detailed download progress with job list:
import {
mdiPause, mdiPlay, mdiClose, mdiCheck, mdiAlertCircle, mdiLoading,
} from '@mdi/js';
import Icon from '@mdi/react';
import { usePreloadProgress } from '../../hooks/usePreloadProgress';
interface DownloadProgressPanelProps {
className?: string;
maxItems?: number;
showWhenEmpty?: boolean;
}
export function DownloadProgressPanel({
className = '',
maxItems = 5,
showWhenEmpty = false,
}: DownloadProgressPanelProps) {
const {
jobs,
activeCount,
completedCount,
errorCount,
totalProgress,
isActive,
clearJob,
clearAllCompleted,
clearAllErrors,
} = usePreloadProgress();
if (!showWhenEmpty && jobs.length === 0) return null;
const visibleJobs = jobs.slice(0, maxItems);
const hiddenCount = jobs.length - maxItems;
return (
<div className={`bg-white border rounded-lg shadow-lg p-3 min-w-[280px] ${className}`}>
{/* Header */}
<div className='flex items-center justify-between mb-2 pb-2 border-b'>
<span className='text-sm font-medium'>Downloads</span>
{isActive && <span className='text-xs text-blue-600'>{activeCount} active</span>}
{completedCount > 0 && (
<button onClick={clearAllCompleted} className='text-xs text-gray-500'>
Clear completed
</button>
)}
</div>
{/* Overall progress */}
{isActive && (
<div className='mb-3'>
<div className='flex justify-between text-xs text-gray-500 mb-1'>
<span>Overall progress</span>
<span>{totalProgress}%</span>
</div>
<div className='w-full h-1.5 bg-gray-200 rounded-full overflow-hidden'>
<div className='h-full bg-blue-500' style={{ width: `${totalProgress}%` }} />
</div>
</div>
)}
{/* Job list */}
<div className='space-y-2'>
{visibleJobs.map((job) => (
<div key={job.id} className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
{/* Status icon */}
{job.status === 'downloading' && (
<Icon path={mdiLoading} size={0.7} className='text-blue-500 animate-spin' />
)}
{job.status === 'completed' && (
<Icon path={mdiCheck} size={0.7} className='text-green-500' />
)}
{job.status === 'error' && (
<Icon path={mdiAlertCircle} size={0.7} className='text-red-500' />
)}
{/* Info */}
<div className='flex-1 min-w-0'>
<div className='text-xs font-medium truncate'>{job.filename}</div>
{job.status === 'downloading' && (
<div className='w-full h-1 bg-gray-200 rounded-full mt-1'>
<div className='h-full bg-blue-500' style={{ width: `${job.progress}%` }} />
</div>
)}
</div>
{/* Close button */}
<button onClick={() => clearJob(job.id)} className='p-0.5 text-gray-400'>
<Icon path={mdiClose} size={0.5} />
</button>
</div>
))}
{hiddenCount > 0 && (
<div className='text-xs text-gray-500 text-center py-1'>
+{hiddenCount} more items
</div>
)}
</div>
</div>
);
}
OfflineStatusIndicator
Small badge showing sync status:
import { mdiCloudCheck, mdiCloudOffOutline, mdiCloudSync } from '@mdi/js';
import Icon from '@mdi/react';
import { useNetworkAware } from '../../hooks/useNetworkAware';
import { usePreloadProgress } from '../../hooks/usePreloadProgress';
interface OfflineStatusIndicatorProps {
className?: string;
showLabel?: boolean;
projectId?: string | null;
}
export function OfflineStatusIndicator({
className = '',
showLabel = false,
}: OfflineStatusIndicatorProps) {
const { networkInfo } = useNetworkAware();
const { isActive, totalProgress } = usePreloadProgress();
// Determine status
let icon = mdiCloudCheck;
let label = 'Online';
let colorClass = 'text-green-500';
let bgClass = 'bg-green-50';
if (!networkInfo.isOnline) {
icon = mdiCloudOffOutline;
label = 'Offline';
colorClass = 'text-gray-500';
bgClass = 'bg-gray-100';
} else if (isActive) {
icon = mdiCloudSync;
label = `Syncing ${totalProgress}%`;
colorClass = 'text-blue-500';
bgClass = 'bg-blue-50';
}
return (
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-full ${bgClass} ${className}`}
title={label}
>
<Icon
path={icon}
size={0.6}
className={`${colorClass} ${isActive ? 'animate-pulse' : ''}`}
/>
{showLabel && (
<span className={`text-xs font-medium ${colorClass}`}>{label}</span>
)}
</div>
);
}
OfflineToggle
Toggle button for offline download:
import { mdiCloudDownload, mdiCloudCheck, mdiCloudOff, mdiDelete } from '@mdi/js';
import Icon from '@mdi/react';
import BaseButton from '../BaseButton';
import { useOfflineMode } from '../../hooks/useOfflineMode';
import { useStorageQuota } from '../../hooks/useStorageQuota';
interface OfflineToggleProps {
projectId: string | null;
projectSlug?: string;
projectName?: string;
className?: string;
showLabel?: boolean;
size?: 'small' | 'medium' | 'large';
}
export function OfflineToggle({
projectId,
projectSlug,
projectName,
className = '',
showLabel = true,
size = 'medium',
}: OfflineToggleProps) {
const {
isOfflineCapable,
isDownloaded,
isDownloading,
status,
progress,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
deleteOfflineData,
estimatedSize,
formatSize,
} = useOfflineMode({ projectId, projectSlug, projectName });
const { canStore, isWarning, isCritical } = useStorageQuota();
if (!isOfflineCapable) return null;
const handleClick = () => {
if (isDownloaded) {
if (confirm('Remove offline data for this project?')) {
deleteOfflineData();
}
} else if (isDownloading) {
pauseDownload();
} else if (status === 'error') {
resumeDownload();
} else {
if (isCritical) {
alert('Storage space is critically low.');
return;
}
startDownload();
}
};
// Determine icon and label based on status
let icon = mdiCloudDownload;
let label = 'Download for offline';
let color: 'info' | 'success' | 'danger' | 'warning' = 'info';
if (isDownloaded) {
icon = mdiCloudCheck;
label = 'Available offline';
color = 'success';
} else if (isDownloading) {
label = `Downloading ${progress}%`;
} else if (status === 'error') {
icon = mdiCloudOff;
label = 'Retry download';
color = 'danger';
}
return (
<div className={`flex items-center gap-2 ${className}`}>
<BaseButton
small={size === 'small'}
color={color}
icon={icon}
label={showLabel ? label : undefined}
onClick={handleClick}
disabled={!canStore(estimatedSize) && !isDownloaded}
/>
{isDownloaded && (
<button onClick={() => deleteOfflineData()} title='Remove offline data'>
<Icon path={mdiDelete} size={0.7} />
</button>
)}
</div>
);
}
StorageUsageDisplay
Visual storage quota indicator:
import { mdiDatabase } from '@mdi/js';
import Icon from '@mdi/react';
import { useStorageQuota } from '../../hooks/useStorageQuota';
interface StorageUsageDisplayProps {
className?: string;
showDetails?: boolean;
compact?: boolean;
}
export function StorageUsageDisplay({
className = '',
showDetails = true,
compact = false,
}: StorageUsageDisplayProps) {
const {
usage,
quota,
percentUsed,
isLoading,
isWarning,
isCritical,
isPersisted,
formatSize,
requestPersistence,
} = useStorageQuota();
if (isLoading) {
return <div className='animate-pulse h-4 bg-gray-200 rounded w-32' />;
}
let barColor = 'bg-blue-500';
let textColor = 'text-gray-600';
if (isCritical) {
barColor = 'bg-red-500';
textColor = 'text-red-600';
} else if (isWarning) {
barColor = 'bg-yellow-500';
textColor = 'text-yellow-600';
}
return (
<div className={`space-y-2 ${className}`}>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Icon path={mdiDatabase} size={0.7} className='text-gray-500' />
<span className='text-sm font-medium text-gray-700'>Storage</span>
</div>
{showDetails && (
<span className={`text-xs ${textColor}`}>
{formatSize(usage)} / {formatSize(quota)}
</span>
)}
</div>
<div className='w-full h-2 bg-gray-200 rounded-full overflow-hidden'>
<div
className={`h-full ${barColor} transition-all duration-300`}
style={{ width: `${Math.min(percentUsed, 100)}%` }}
/>
</div>
{showDetails && (
<div className='flex items-center justify-between text-xs'>
<span className={textColor}>
{isCritical ? 'Storage critically low' :
isWarning ? 'Storage running low' :
`${Math.round(100 - percentUsed)}% available`}
</span>
{!isPersisted && (
<button onClick={requestPersistence} className='text-blue-500'>
Make persistent
</button>
)}
{isPersisted && (
<span className='text-green-600'>Persistent storage enabled</span>
)}
</div>
)}
</div>
);
}
Progress Calculation
const calculateTotalProgress = (jobs: PreloadJob[]): number => {
if (jobs.length === 0) return 0;
const activeJobs = jobs.filter(
(j) => j.status !== 'completed' && j.status !== 'error',
);
if (activeJobs.length === 0) return 100;
const totalBytes = activeJobs.reduce((sum, j) => sum + j.totalBytes, 0);
const loadedBytes = activeJobs.reduce((sum, j) => sum + j.bytesLoaded, 0);
if (totalBytes === 0) {
// Fallback to count-based progress
const completedCount = jobs.filter((j) => j.status === 'completed').length;
return Math.round((completedCount / jobs.length) * 100);
}
return Math.round((loadedBytes / totalBytes) * 100);
};
Auto-Cleanup
Jobs are automatically removed after completion/error:
// From PRELOAD_CONFIG
autoRemove: {
completedMs: 3000, // Remove completed jobs after 3 seconds
errorMs: 10000, // Remove error jobs after 10 seconds
}
This prevents the jobs array from growing indefinitely and provides a brief visual confirmation before cleanup.
Type Definitions
PreloadJobStatus
type PreloadJobStatus =
| 'queued' // In queue, not yet started
| 'downloading' // Currently downloading
| 'completed' // Successfully completed
| 'error' // Failed with error
| 'paused'; // Paused by user
AssetVariantType
type AssetVariantType =
| 'thumbnail' // Small preview
| 'preview' // Medium preview
| 'webp' // WebP optimized
| 'mp4_low' // Low-res video
| 'mp4_high' // High-res video
| 'original'; // Original file
AssetType
type AssetType =
| 'image' // Image files
| 'video' // Video files
| 'audio' // Audio files
| 'transition' // Transition videos
| 'other'; // Other file types
Event Payloads
interface PreloadStartEvent {
jobId: string;
assetId: string;
url: string;
}
interface PreloadProgressEvent {
jobId: string;
progress: number; // 0-100
bytesLoaded: number;
totalBytes: number;
}
interface PreloadCompleteEvent {
jobId: string;
assetId: string;
}
interface PreloadErrorEvent {
jobId: string;
assetId: string;
error: string;
}
interface ProjectDownloadProgressEvent {
projectId: string;
progress: number;
downloadedAssets: number;
totalAssets: number;
downloadedBytes: number;
totalBytes: number;
}
interface ProjectDownloadCompleteEvent {
projectId: string;
}
Best Practices
1. Use Optional Hook Outside Provider
// Good - won't crash if outside provider
const ctx = useDownloadContextOptional();
if (ctx?.isDownloading) { ... }
// Bad - will crash if outside provider
const ctx = useDownloadContext(); // throws Error
2. Memoize Computed Values
// Good - values memoized in context
const { activeCount, totalProgress } = useDownloadContext();
// Avoid recomputing in consumers
const activeCount = useMemo(
() => jobs.filter(j => j.status === 'downloading').length,
[jobs]
); // Already computed by context
3. Use Appropriate State Source
// For global UI (headers, status bars)
const ctx = useDownloadContext();
// For isolated components
const progress = usePreloadProgress();
4. Clean Up Jobs
// Clear completed jobs when user dismisses
<button onClick={clearAllCompleted}>Clear completed</button>
// Clear specific job
<button onClick={() => clearJob(job.id)}>×</button>
File Inventory
| File | LOC | Purpose |
|---|---|---|
DownloadContext.tsx |
256 | Context provider and hooks |
DownloadEventBus.ts |
180 | Event emitter (related) |
usePreloadProgress.ts |
221 | Hook alternative (related) |
offline.ts (types) |
195 | Type definitions (related) |
offline.config.ts |
53 | Event names, storage config (related) |
preload.config.ts |
95 | Priority, auto-cleanup (related) |
| Core Context | 256 | |
| With Related | 1,000 |
Related Documentation
- hooks-module.md - usePreloadProgress, useOfflineMode, useStorageQuota
- lib-module.md - DownloadEventBus, StorageManager
- config-module.md - OFFLINE_CONFIG, PRELOAD_CONFIG
- types-module.md - Offline types
- components-module.md - Offline components
Adding New Contexts
To add a new React Context:
Step 1: Create Context File
// context/MyContext.tsx
import React, { createContext, useContext, useState, type ReactNode } from 'react';
interface MyContextValue {
value: string;
setValue: (v: string) => void;
}
const MyContext = createContext<MyContextValue | null>(null);
export function MyProvider({ children }: { children: ReactNode }) {
const [value, setValue] = useState('');
return (
<MyContext.Provider value={{ value, setValue }}>
{children}
</MyContext.Provider>
);
}
export function useMyContext(): MyContextValue {
const ctx = useContext(MyContext);
if (!ctx) {
throw new Error('useMyContext must be used within MyProvider');
}
return ctx;
}
export function useMyContextOptional(): MyContextValue | null {
return useContext(MyContext);
}
Step 2: Add Provider to _app.tsx
import { MyProvider } from '../context/MyContext';
function MyApp({ Component, pageProps }) {
return (
<Provider store={store}>
<DownloadProvider>
<MyProvider>
{/* ... */}
</MyProvider>
</DownloadProvider>
</Provider>
);
}
Step 3: Consume in Components
import { useMyContext } from '@/context/MyContext';
function MyComponent() {
const { value, setValue } = useMyContext();
// ...
}