Instructor Course Editor Architecture
Instructor Course Editor Architecture
Version: 1.0 Last Updated: 2025-11-02 Maintainers: Engineering Team
Table of Contents
- Overview
- Architecture Principles
- System Components
- State Management
- Data Flow
- Navigation System
- Step Handler Registry Pattern
- Entity Lifecycle Management
- Persistence Strategy
- Error Handling
- Performance Optimizations
- Key Design Patterns
- Extension Points
Overview
The Instructor Course Editor is a multi-step wizard for creating and editing courses. It implements a sophisticated state-driven architecture using Zustand for state management, with a step-based navigation system, optimistic UI updates, and robust error handling.
Key Features
- 5-Step Wizard: Basic Info → Advanced Info → Curriculum → Pricing → Publish
- Optimistic Updates: UI responds immediately, syncs async with backend
- Persistence: Critical data persists across sessions
- Permission Control: Owner/manager authorization checks
- Unsaved Changes Protection: Prevents data loss on navigation
- Real-time Validation: Per-step validation with error tracking
- Entity Status Tracking: NEW → UPDATED → SYNCED → DELETED states
Technology Stack
- State Management: Zustand with slices pattern
- Forms: React Hook Form + Zod validation
- Data Fetching: React Query
- UI Framework: Chakra UI v3
- Navigation: React Router v6
- Animations: Framer Motion
Architecture Principles
1. Separation of Concerns
- Parent Component: Initial data fetch, routing, permission checks
- Step Components: Individual UI, validation, save logic
- Store Slices: Domain-specific state management
- Hooks: Reusable logic encapsulation
2. Single Source of Truth
- Zustand store is the canonical source for all editor state
- React Query for server state caching
- LocalStorage for session persistence
3. Loose Coupling
- Steps don’t know about each other
- Navigation logic separated from step logic
- Save handlers registered dynamically via registry pattern
4. Progressive Enhancement
- Works with partial data
- Handles network failures gracefully
- Supports continue-previous-work flow
5. Type Safety
- Full TypeScript coverage
- Zod schemas for runtime validation
- Interface contracts between layers
System Components
Component Hierarchy
CourseEditor (wrapper with error boundary)└── CourseEditorContent ├── ContinueWorkModal ├── UnauthorizedAccessModal └── Tabs ├── TabList (navigation) └── TabPanels (step content) ├── BasicInformation ├── AdvanceInformation ├── Curriculum ├── Pricing └── PublishCourseCore Files
Pages (instructor/src/pages/editor/)
CourseEditor.tsx- Main wrapper with data fetchingBasicInformation.tsx- Course title, category, level, languageAdvanceInformation.tsx- Thumbnail, video, description, requirementsCurriculum.tsx- Course structure (sections/subsections/items)Pricing.tsx- Pricing configurationPublishCourse.tsx- Final review and submission
Store Slices (instructor/src/stores/course-editor/)
index.ts- Main store creator with persistencestate.ts- Initial state definitiontypes.ts- Initial values for each stepnavigation.slice.ts- Step navigation logicform.slice.ts- Form state managementcurriculum.slice.ts- Course structure operationssync.slice.ts- Backend synchronizationstep.slice.ts- Step completion trackingstep-handler-registry.ts- Handler registry implementation
Hooks (instructor/src/hooks/)
useEditorNavigation.ts- Enhanced navigation with validationuseCourseEditorInitialization.ts- Editor initialization logicuseStepSubmission.ts- Step handler registration
State Management
Zustand Store Structure
interface CourseEditorStore { // Mode mode: EditorMode.CREATE | EditorMode.EDIT; courseId: string | null;
// Step Data basicInfo: IBasicInfo; advancedInfo: IAdvancedInfo; sections: Record<string, Section>; subsections: Record<string, Subsection>; items: Record<string, CourseItem>; pricing: Pricing; publish: Publish;
// UI State currentIndex: number; isSubmitting: boolean; isLoading: boolean; isDisabled: boolean;
// Step Tracking stepStates: Record<StepKey, StepState>; navigationState: NavigationState; optimisticUpdates: Record<string, any>;
// Registry stepHandlerRegistry: StepHandlerRegistry;}Slice Pattern
The store is composed of 5 specialized slices:
1. Navigation Slice
interface NavigationSlice { _next: (step?: number) => void; _prev: (step?: number) => void; _jump: (step: number) => void; refreshStepAccessibility: () => void;}Responsibilities:
- Step index management
- Accessibility validation
- Mode switching (CREATE → EDIT)
- Step completion checking
2. Form Slice
interface FormSlice { setBasicInfo: (field: keyof IBasicInfo, value: any) => void; setAdvancedInfo: (field: keyof IAdvancedInfo, value: any) => void; setPricing: (field: keyof Pricing, value: any) => void; setPublish: (field: keyof Publish, value: any) => void; validateCurrentStep: () => ValidationResult; validateStepData: (stepKey: StepKey) => ValidationResult;}Responsibilities:
- Field-level updates
- Real-time validation
- Form state tracking
3. Curriculum Slice
interface CurriculumSlice { // CRUD Operations addSection: (courseId: string, title?: string) => MutationInfo; updateSection: (id: string, updates: Partial<Section>) => MutationInfo; removeSection: (id: string) => MutationInfo;
addSubsection: (sectionId: string, title?: string) => MutationInfo; updateSubsection: (id: string, updates: Partial<Subsection>) => MutationInfo; removeSubsection: (sectionId: string, id: string) => MutationInfo;
addItem: (subsectionId: string, type: CourseItemType, title?: string) => MutationInfo; updateItem: (id: string, updates: Partial<CourseItem>) => MutationInfo; removeItem: (subsectionId: string, id: string) => MutationInfo;
// Reordering moveSectionUpDown: (id: string, direction: 'up' | 'down') => Array<MutationInfo>; moveSubsectionUpDown: (sectionId: string, id: string, direction: 'up' | 'down') => Array<MutationInfo>; moveItemUpDown: (subsectionId: string, id: string, direction: 'up' | 'down') => Array<MutationInfo>;}Responsibilities:
- Hierarchical structure management
- Optimistic UI updates with temp IDs
- Order management with automatic reindexing
- Cascade deletion
4. Sync Slice
interface SyncSlice { populateStore: (data: CourseResponseDto) => void; populateCurriculum: (data: CurriculumDto, options?: { replaceExisting?: boolean }) => void;
confirmSectionCreation: (tempId: string, serverData: Section) => void; confirmSectionUpdate: (id: string, serverData: Section) => void; confirmSectionDeletion: (id: string) => void;
// Similar for subsections and items...
setEntitySyncError: (type: string, id: string, error: any, action: string, payload: any) => void;}Responsibilities:
- Initial data hydration
- Temp ID → Real ID replacement
- Entity status updates (SYNCED/ERROR)
- Error tracking and recovery
5. Step Slice
interface StepSlice { markStepDirty: (stepKey: StepKey) => void; markStepSaving: (stepKey: StepKey, isSaving: boolean) => void; markStepSaved: (stepKey: StepKey, data?: any) => void; markStepError: (stepKey: StepKey, errors: Record<string, string>) => void;
checkStepCompletion: (stepKey: StepKey) => boolean; getStepCompletionStatus: (stepKey: StepKey) => 'pending' | 'current' | 'completed' | 'error';
hasUnsavedChanges: () => boolean; getUnsavedSteps: () => StepKey[];
initializeStepStatesFromCourse: () => void;}Responsibilities:
- Step state lifecycle
- Completion tracking
- Unsaved changes detection
- Step accessibility calculation
Data Flow
1. Initial Load (Edit Mode)
User navigates to /editor/:courseId/basic ↓CourseEditor.tsx mounts ↓React Query fetches course data - CourseService.getCourseById(courseId) - CourseService.getNormalizedCurriculum(courseId) ↓populateStore(courseData) - Hydrates basicInfo, advancedInfo, pricing, publish ↓populateCurriculum(curriculumData) - Hydrates sections, subsections, items - Marks all entities as SYNCED ↓initializeStepStatesFromCourse() - Validates each step's data - Sets completion status - Calculates accessibility ↓Permission check - isOwner || isManager? - If false: Show unauthorized modal ↓Editor ready2. Step Save Flow
User fills form → Clicks "Next" ↓useEditorNavigation.navigateToStep(nextIndex) ↓saveCurrentStep() ↓Get handler from registry ↓handler.validateForm() (if exists) ↓handler.onSave() ↓Step component's onSubmit() - Calls CourseService.updateCourse(courseId, data) - Uses withRetry for resilience ↓On success: - markStepSaved(stepKey) - Update store with server response - Invalidate React Query cache ↓On failure: - markStepError(stepKey, errors) - Show toast notification - Stay on current step ↓Navigation proceeds if successful3. Curriculum Entity Creation Flow
User clicks "Add Section" ↓addSection(courseId, "New Section") ↓Generate tempId: "temp-${uuid}" ↓Create optimistic section in store { id: tempId, title: "New Section", entityStatus: EntityStatus.NEW, ... } ↓UI updates immediately ↓Component triggers mutation CourseSectionService.createSection(payload) ↓On success (server returns real ID): confirmSectionCreation(tempId, serverSection) - Remove temp entry - Add real entry with entityStatus: SYNCED ↓On failure: setEntitySyncError('section', tempId, error, 'create', payload) - Mark as SYNC_ERROR - Store payload for retry4. Curriculum Entity Update Flow
User edits section title ↓updateSection(sectionId, { title: "New Title" }) ↓Update store immediately entityStatus: UPDATED (if was SYNCED) entityStatus: NEW (if was NEW - not yet saved) ↓UI updates immediately ↓Component triggers mutation CourseSectionService.updateSection(id, payload) ↓On success: confirmSectionUpdate(sectionId, serverSection) - Update with server data - entityStatus: SYNCED ↓On failure: setEntitySyncError('section', sectionId, error, 'update', payload, originalData) - Restore original data (optional) - Mark as SYNC_ERROR5. Curriculum Entity Deletion Flow
User clicks "Delete Section" ↓removeSection(sectionId) ↓Check entityStatus:
If NEW (never saved): - Remove from store immediately - Remove all child subsections/items - Reorder remaining sections - Return { clientOnly: true } ↓If SYNCED or UPDATED: - Mark as DELETED in store - Hide from UI - Return { idToDelete: sectionId } ↓Component triggers mutation CourseSectionService.deleteSection(id) ↓On success: confirmSectionDeletion(sectionId) - Remove from store - Remove all children - Reorder remaining ↓On failure: setEntitySyncError('section', sectionId, error, 'delete', {}, originalData) - Restore to original state - Show errorNavigation System
Navigation Hook (useEditorNavigation)
The useEditorNavigation hook is the central navigation controller for the editor.
Core Methods
interface UseEditorNavigationReturn { // Navigation navigateToStep: (targetStep: EditorStep | number) => Promise<boolean>; navigateNext: () => Promise<boolean>; navigatePrev: () => Promise<boolean>;
// Save operations saveCurrentStep: () => Promise<boolean>; saveAndNavigateNext: () => Promise<boolean>;
// State queries isStepAccessible: (step: EditorStep | number) => boolean; getStepStatus: (step: EditorStep | number) => 'current' | 'completed' | 'pending' | 'error'; getStepAccessibilityReason: (step: EditorStep | number) => string | null;
// Unsaved changes hasUnsavedChanges: () => boolean; getUnsavedSteps: () => StepKey[];
// Route blocking pendingNavigation: { targetStep: number; saveFirst: boolean } | null; confirmNavigation: () => void; cancelNavigation: () => void; saveAndNavigate: () => Promise<void>;
// Modal props modalProps: { isOpen: boolean; onClose: () => void; onConfirm: () => void; onDiscard: () => void; isSaving: boolean; };}Navigation Rules
// Forward navigation: Auto-saves current stepif (targetIndex > currentIndex) { const saveSuccess = await saveCurrentStep(); if (!saveSuccess) return false;}
// Step 0 is always accessibleif (targetIndex === 0) return true;
// Backward navigation: Always allowedif (targetIndex < currentIndex) return true;
// Forward navigation requires:// 1. courseId exists (created in step 0)if (!courseId) return false;
// 2. Previous steps are completed or have valid datafor (let i = 0; i < targetIndex; i++) { const stepKey = getStepKeyByIndex(i); if (!checkStepCompletion(stepKey) && !courseId) { return false; }}
// 3. Current step has no errorsconst currentStepState = stepStates[currentStepKey];if (currentStepState.hasErrors) return false;URL Synchronization
useEffect(() => { const buildEditorUrl = (courseId: string | null, stepName: string) => { if (courseId) return `/editor/${courseId}/${stepName}`; return `/editor/new/${stepName}`; };
const expectedUrl = buildEditorUrl(courseId, stepValues[currentIndex]); if (currentPath !== expectedUrl) { navigate(expectedUrl, { replace: true }); }}, [currentIndex, courseId]);Unsaved Changes Protection
const blocker = useBlocker( ({ currentLocation, nextLocation }) => hasUnsavedChanges() && currentLocation.pathname !== nextLocation.pathname);
useEffect(() => { if (blocker.state === 'blocked') { // Show modal: Save, Discard, or Cancel? onOpen(); setPendingNavigation({ targetStep: -1, saveFirst: false }); }}, [blocker]);Step States
Each step tracks its own state:
interface StepState { // Validation isValid: boolean; hasErrors: boolean; errors: Record<string, string>;
// Data hasValidData: boolean;
// Lifecycle isDirty: boolean; isSaving: boolean; lastSaved: Date | null;
// Accessibility isAccessible: boolean; completionStatus: 'pending' | 'current' | 'completed' | 'error';}Visual Feedback
Steps are visually styled based on their state:
const getStepColor = () => { if (isDisabled) return 'gray.400'; if (stepStatus === 'completed') return 'green.500'; if (stepStatus === 'current') return 'blue.500'; if (stepStatus === 'error') return 'red.500'; return 'inherit';};
const getTabStyles = () => { if (stepStatus === 'completed') { return { borderLeft: '3px solid', borderLeftColor: 'green.500', }; } if (stepStatus === 'current') { return { bg: 'blue.50', borderLeft: '3px solid', borderLeftColor: 'blue.500', }; } if (stepStatus === 'error') { return { borderLeft: '3px solid', borderLeftColor: 'red.500', }; } return {};};Step Handler Registry Pattern
Overview
The Step Handler Registry decouples navigation logic from step-specific business logic. Each step registers a handler that implements a common interface, allowing the navigation system to call step-specific functions without knowing implementation details.
Handler Interface
interface StepHandler { // Save/Discard onSave: () => Promise<StepSaveResult>; onDiscard: () => void;
// Validation validateForm?: () => boolean | Promise<boolean>;
// State readonly hasUnsavedChanges: boolean; readonly hasErrors: boolean; readonly isSaving: boolean;}
interface StepSaveResult { success: boolean; errors?: Record<string, string>; queryKeysToInvalidate?: Array<unknown[]>;}Registry Implementation
class StepHandlerRegistry { private handlers = new Map<StepKey, StepHandler>();
register(stepKey: StepKey, handler: StepHandler): void { this.handlers.set(stepKey, handler); }
unregister(stepKey: StepKey): void { this.handlers.delete(stepKey); }
get(stepKey: StepKey): StepHandler | undefined { return this.handlers.get(stepKey); }}Step Registration Hook
const useStepSubmission = ({ stepKey, onSave, validateForm, onDiscard, hasUnsavedChanges, hasErrors,}: UseStepSubmissionOptions): UseStepSubmissionReturn => { const [isSaving, setIsSaving] = useState(false); const stepHandlerRegistry = useCourseEditorStore((state) => state.stepHandlerRegistry);
const handleSave = useCallback(async (): Promise<StepSaveResult> => { setIsSaving(true); try { // Run validation if provided if (validateForm) { const isValid = await Promise.resolve(validateForm()); if (!isValid) { return { success: false, errors: { general: 'Validation failed' } }; } }
// Execute save const result = await onSave();
if (result.success) { useCourseEditorStore.getState().markStepSaved(stepKey); } else { useCourseEditorStore.getState().markStepError(stepKey, result.errors || {}); }
return result; } finally { setIsSaving(false); } }, [stepKey, onSave, validateForm]);
// Register handler on mount useEffect(() => { const handler: StepHandler = { onSave: handleSave, onDiscard, validateForm, get isSaving() { return isSaving; }, get hasUnsavedChanges() { return hasUnsavedChanges; }, get hasErrors() { return hasErrors; }, };
stepHandlerRegistry.register(stepKey, handler);
return () => { stepHandlerRegistry.unregister(stepKey); }; }, [stepKey, stepHandlerRegistry, handleSave]);
return { isSaving };};Usage in Step Components
const BasicInformation = () => { const { basicInfo, setBasicInfo, courseId, mode, setCourseId } = useCourseEditorStore(); const navigation = useEditorNavigation();
const { formState: { errors, isDirty, isValid }, getValues, reset, } = useForm<IBasicInfo>({ resolver: zodResolver(basicInfoSchema), defaultValues: basicInfo, });
const onSubmit = useCallback(async (): Promise<StepSaveResult> => { const data = getValues();
try { if (mode === EditorMode.CREATE) { const res = await CourseService.createCourse(data); setCourseId(res.id); return { success: true }; } else { await CourseService.updateCourse(courseId, data); return { success: true, queryKeysToInvalidate: [['course', courseId, 'details']] }; } } catch (error) { return { success: false, errors: { general: error.message } }; } }, [mode, courseId, getValues]);
const handleDiscard = useCallback(() => { reset(basicInfo); }, [basicInfo, reset]);
useStepSubmission({ stepKey: 'basic', onSave: onSubmit, onDiscard: handleDiscard, hasUnsavedChanges: isDirty, hasErrors: !isValid, });
return ( <form> {/* Form fields */} <ButtonArrow onClick={() => navigation.saveAndNavigateNext()}> Next </ButtonArrow> </form> );};Benefits
- Decoupling: Navigation doesn’t know about step internals
- Testability: Each handler can be tested independently
- Flexibility: Easy to add new steps without modifying navigation
- Type Safety: Common interface ensures consistency
- Reusability: Handler logic can be shared across steps
Entity Lifecycle Management
Entity Status Enum
enum EntityStatus { NEW = 'new', // Created locally, not yet saved UPDATED = 'updated', // Modified after being synced SYNCED = 'synced', // In sync with backend DELETED = 'deleted', // Marked for deletion SYNC_ERROR = 'error', // Failed to sync}Entity Structure
interface Section { id: string; title: string; courseId: UUID; order: number; isActive: boolean; subsectionIds: string[];
// Lifecycle fields entityStatus: EntityStatus; syncErrorDetails?: { errorMessage: string; attemptedAction: 'create' | 'update' | 'delete'; payload: any; originalDataIfUpdate?: Section; };
// Timestamps createdAt: Date; updatedAt: Date;}Lifecycle Transitions
┌──────────────┐ │ User Action │ └──────┬───────┘ │ ▼ ┌──────────────────────────┐ │ Create entity in UI │ │ entityStatus: NEW │ │ id: temp-${uuid} │ └──────────┬───────────────┘ │ ▼ ┌──────────────────────────┐ │ Trigger API mutation │ └──────┬────────────┬──────┘ │ │ ┌──────────▼────┐ ┌───▼──────────┐ │ Success │ │ Failure │ │ │ │ │ │ Replace temp │ │ Mark as ERROR │ │ with real ID │ │ Store payload │ │ SYNCED status │ │ Show retry UI │ └───────────────┘ └───────────────┘Update Flow
Entity with status: SYNCED │ ▼User modifies field │ ▼entityStatus = UPDATEDupdatedAt = new Date() │ ▼Trigger API mutation │ ┌────┴────┐ │ │Success Failure │ │SYNCED SYNC_ERROR Store original dataDelete Flow
Entity marked for deletion │ ┌────┴────┐ │ │ NEW SYNCED/UPDATED │ │ │ ▼ │ entityStatus = DELETED │ Hide from UI │ Don't remove from store yet │ │ │ ▼ │ Trigger API mutation │ │ │ ┌────┴────┐ │ │ │ │ Success Failure │ │ │ │ │ Restore entity │ │ Show error │ │ └────┴────────▶ Remove from storeTemporary IDs
// Generationconst tempId = `temp-${uuidv4()}`;
// Identificationconst isTemporary = (id: string) => id.startsWith('temp-');
// Replacement after successful creationconfirmSectionCreation(tempId, serverSection) { const { [tempId]: _, ...restSections } = state.sections; return { sections: { ...restSections, [serverSection.id]: { ...serverSection, entityStatus: EntityStatus.SYNCED, }, }, };}Error Recovery
// Sync error trackinginterface SyncErrorDetails { errorMessage: string; attemptedAction: 'create' | 'update' | 'delete'; payload: any; originalDataIfUpdate?: Section | Subsection | CourseItem;}
// Retry mechanismconst retryFailedEntity = async (entity: Section) => { const { syncErrorDetails } = entity; if (!syncErrorDetails) return;
try { switch (syncErrorDetails.attemptedAction) { case 'create': const res = await CourseSectionService.createSection(syncErrorDetails.payload); confirmSectionCreation(entity.id, res); break;
case 'update': await CourseSectionService.updateSection(entity.id, syncErrorDetails.payload); confirmSectionUpdate(entity.id, syncErrorDetails.payload); break;
case 'delete': await CourseSectionService.deleteSection(entity.id); confirmSectionDeletion(entity.id); break; } } catch (error) { // Still failed, keep error state console.error('Retry failed:', error); }};Persistence Strategy
LocalStorage Persistence
Only critical data is persisted to avoid bloating localStorage:
export const useCourseEditorStore = create<CourseEditorStore>()( persist( storeCreator, { name: 'course-editor-storage', partialize: (state) => ({ courseId: state.courseId, basicInfo: state.basicInfo, advancedInfo: state.advancedInfo, pricing: state.pricing, publish: state.publish, }), } ));Not Persisted:
sections,subsections,items(curriculum)stepStates(recomputed on mount)navigationState(derived)optimisticUpdates(transient)- UI state flags
Rationale:
- Curriculum can be large and is fetched from server
- Step states are derived from validated data
- Reduces localStorage quota usage
- Prevents stale curriculum data
Continue Work Modal
When the editor mounts:
const useCourseEditorInitialization = () => { const [showContinueModal, setShowContinueModal] = useState(false); const [staleCourseTitle, setStaleCourseTitle] = useState<string | null>(null);
useEffect(() => { const persistedCourseId = localStorage.getItem('course-editor-storage'); if (!persistedCourseId) return;
// Fetch server's lastModified const serverData = await CourseService.getCourseById(persistedCourseId); const localModified = localStorage.getItem('lastModified');
if (serverData.updatedAt !== localModified) { setStaleCourseTitle(serverData.title); setShowContinueModal(true); } }, []);
const continuePreviousWork = () => { // Keep localStorage state setShowContinueModal(false); };
const startFresh = () => { // Clear localStorage localStorage.removeItem('course-editor-storage'); resetStore(); setShowContinueModal(false); };
return { showContinueModal, continuePreviousWork, startFresh };};Error Handling
Multi-Layer Error Boundaries
1. Application Level
// CourseEditorErrorBoundary<ErrorBoundary FallbackComponent={CourseEditorErrorFallback} onError={(error, info) => { console.error('Course Editor Error:', error, info); // Send to error tracking service }}> <CourseEditorContent /></ErrorBoundary>2. Step Level
- React Hook Form validation errors
- Zod schema validation
- Displayed inline near fields
3. API Level
try { await CourseService.updateCourse(courseId, data);} catch (error) { const message = handleError(error); // Utility to extract message toast({ title: "Save Failed", description: message, status: "error", }); return { success: false, errors: { general: message } };}4. Navigation Guards
if (!isStepAccessible(targetIndex)) { toast({ title: "Cannot Navigate", description: getStepAccessibilityReason(targetIndex), status: "warning", }); return false;}Retry Logic
const withRetry = async <T>( fn: () => Promise<T>, operationName: string, options: { maxRetries?: number; baseDelay?: number; maxDelay?: number; } = {}): Promise<T> => { const { maxRetries = 3, baseDelay = 1000, maxDelay = 5000 } = options;
let lastError: Error;
for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error;
if (attempt < maxRetries) { const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); console.log(`Retry ${attempt + 1}/${maxRetries} for ${operationName} after ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } } }
throw lastError;};Permission Errors
useEffect(() => { if (!fetchedCourseData || !profile.data) return;
const isOwner = fetchedCourseData.instructor?.user?.id === profile.data.id; const isManager = fetchedCourseData.managers?.some( m => m.user?.id === profile.data.id );
if (!isOwner && !isManager) { setIsDisabled(true); onOpen(); // Show UnauthorizedAccessModal return; }
setIsDisabled(false);}, [fetchedCourseData, profile.data]);Performance Optimizations
1. Memoization
// Memoize curriculum array computationconst curriculumArray = useMemo(() => Object.values(sections) .filter(section => section.entityStatus !== 'deleted') .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) .map(section => ({ ...section, subsections: Object.values(subsections) .filter(sub => sub.sectionId === section.id && sub.entityStatus !== 'deleted') .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) })), [sections, subsections, items]);2. Shallow Equality Checking
import { shallow } from 'zustand/shallow';
const { sections, subsections, items } = useCourseEditorStore( state => ({ sections: state.sections, subsections: state.subsections, items: state.items }), shallow // Only re-render if references change);3. Debounced Validation
const debouncedValidate = debounce(() => { const result = validateCurrentStep(); if (!result.success) { markStepError(currentStepKey, result.errors); } else { markStepClean(currentStepKey); }}, 300);
// Called on field changesconst updateStepField = (stepKey, field, value) => { // Update immediately setBasicInfo(field, value); markStepDirty(stepKey);
// Validate after debounce debouncedValidate();};4. Conditional Queries
// Only fetch curriculum if not already in storeconst { data: curriculumData } = useQuery({ queryKey: ['course', courseId, 'curriculum'], queryFn: () => CourseService.getNormalizedCurriculum(courseId), enabled: !!courseId && Object.keys(sections).length === 0, refetchOnWindowFocus: false,});5. Optimistic UI Updates
// UI updates immediatelyaddSection(courseId, title);
// Backend sync happens asyncuseMutation({ mutationFn: (payload) => CourseSectionService.createSection(payload), onSuccess: (serverSection) => { confirmSectionCreation(tempId, serverSection); }, onError: (error) => { setEntitySyncError('section', tempId, error, 'create', payload); },});6. Selective Re-renders
// Use memo to prevent unnecessary re-rendersconst DebouncedCharacterCounter = memo(({ value, maxLength }) => { const [displayValue, setDisplayValue] = useState(value?.length || 0);
useEffect(() => { const timer = setTimeout(() => { setDisplayValue(value?.length || 0); }, 100); return () => clearTimeout(timer); }, [value]);
return <Text>{displayValue}/{maxLength}</Text>;});7. Animation Performance
// Use Framer Motion layout animations<motion.div layout initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.2 }}> {/* Content */}</motion.div>Key Design Patterns
1. Command Pattern (Step Handlers)
- Encapsulates save/discard operations as objects
- Allows queuing, undo, and retry
2. Registry Pattern (Handler Registry)
- Dynamic registration of step handlers
- Loose coupling between navigation and steps
3. Observer Pattern (Zustand Subscriptions)
- Components subscribe to specific slices
- Automatic re-renders on state changes
4. State Machine (Step Progression)
- Finite states: pending → current → completed → error
- Well-defined transitions
5. Optimistic Concurrency (Entity Status)
- UI updates immediately
- Backend syncs asynchronously
- Handles conflicts gracefully
6. Repository Pattern (Services)
- CourseService, CourseSectionService, etc.
- Abstracts API communication
7. Facade Pattern (Navigation Hook)
- Simplifies complex navigation logic
- Single interface for step navigation
8. Strategy Pattern (Validation)
- Different validation strategies per step
- Zod schemas define validation rules
Extension Points
Adding a New Step
- Create step component (
pages/editor/NewStep.tsx) - Add to initial state (
stores/course-editor/types.ts) - Create form slice methods (if needed)
- Add validation schema (
utils/schema/course.schema.ts) - Register in navigation (
EditorStepenum) - Add to tabs array (
CourseEditor.tsx) - Implement step handler (using
useStepSubmission)
Adding a New Entity Type
- Define interface (
utils/types.ts) - Add to store state (
stores/course-editor/state.ts) - Create CRUD methods (new or existing slice)
- Add sync methods (
sync.slice.ts) - Create service (
services/new-entity.service.ts) - Build UI component (
components/course/NewEntity.tsx)
Custom Validation
// Add to schemaexport const customStepSchema = z.object({ field: z.string().refine( (val) => customValidationLogic(val), { message: "Custom validation failed" } ),});
// Use in stepconst { formState: { errors }, trigger } = useForm({ resolver: zodResolver(customStepSchema),});Additional Hooks
// Example: Auto-save hookconst useAutoSave = (stepKey: StepKey, interval: number = 60000) => { const saveCurrentStep = useEditorNavigation().saveCurrentStep;
useEffect(() => { const timer = setInterval(() => { saveCurrentStep(); }, interval);
return () => clearInterval(timer); }, [stepKey, saveCurrentStep, interval]);};Conclusion
The Instructor Course Editor demonstrates a sophisticated, production-ready architecture that balances:
- User Experience: Instant feedback, clear progression, unsaved changes protection
- Data Integrity: Validation at multiple levels, optimistic updates with rollback
- Performance: Memoization, shallow equality, debounced validation
- Maintainability: Clear separation of concerns, typed interfaces, extensible patterns
- Resilience: Retry logic, error boundaries, graceful degradation
This architecture can serve as a template for other multi-step wizards and complex form flows in the application.
Document History:
- v1.0 (2025-11-02): Initial architecture documentation