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
└── PublishCourse
Core 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 ready
2. 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 successful
3. 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 retry
4. 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_ERROR
5. 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 error
Navigation 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 step
if (targetIndex > currentIndex) {
const saveSuccess = await saveCurrentStep();
if (!saveSuccess) return false;
}
// Step 0 is always accessible
if (targetIndex === 0) return true;
// Backward navigation: Always allowed
if (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 data
for (let i = 0; i < targetIndex; i++) {
const stepKey = getStepKeyByIndex(i);
if (!checkStepCompletion(stepKey) && !courseId) {
return false;
}
}
// 3. Current step has no errors
const 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
// BasicInformation.tsx
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 = UPDATED
updatedAt = new Date()
│
▼
Trigger API mutation
│
┌────┴────┐
│ │
Success Failure
│ │
SYNCED SYNC_ERROR
Store original data
Delete 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 store
Temporary IDs
// Generation
const tempId = `temp-${uuidv4()}`;
// Identification
const isTemporary = (id: string) => id.startsWith('temp-');
// Replacement after successful creation
confirmSectionCreation(tempId, serverSection) {
const { [tempId]: _, ...restSections } = state.sections;
return {
sections: {
...restSections,
[serverSection.id]: {
...serverSection,
entityStatus: EntityStatus.SYNCED,
},
},
};
}
Error Recovery
// Sync error tracking
interface SyncErrorDetails {
errorMessage: string;
attemptedAction: 'create' | 'update' | 'delete';
payload: any;
originalDataIfUpdate?: Section | Subsection | CourseItem;
}
// Retry mechanism
const 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 computation
const 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 changes
const 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 store
const { 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 immediately
addSection(courseId, title);
// Backend sync happens async
useMutation({
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-renders
const 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 schema
export const customStepSchema = z.object({
field: z.string().refine(
(val) => customValidationLogic(val),
{ message: "Custom validation failed" }
),
});
// Use in step
const { formState: { errors }, trigger } = useForm({
resolver: zodResolver(customStepSchema),
});
Additional Hooks
// Example: Auto-save hook
const 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