Skip to main content

Instructor Course Editor Architecture

Version: 1.0 Last Updated: 2025-11-02 Maintainers: Engineering Team


Table of Contents

  1. Overview
  2. Architecture Principles
  3. System Components
  4. State Management
  5. Data Flow
  6. Navigation System
  7. Step Handler Registry Pattern
  8. Entity Lifecycle Management
  9. Persistence Strategy
  10. Error Handling
  11. Performance Optimizations
  12. Key Design Patterns
  13. 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 fetching
  • BasicInformation.tsx - Course title, category, level, language
  • AdvanceInformation.tsx - Thumbnail, video, description, requirements
  • Curriculum.tsx - Course structure (sections/subsections/items)
  • Pricing.tsx - Pricing configuration
  • PublishCourse.tsx - Final review and submission

Store Slices (instructor/src/stores/course-editor/)

  • index.ts - Main store creator with persistence
  • state.ts - Initial state definition
  • types.ts - Initial values for each step
  • navigation.slice.ts - Step navigation logic
  • form.slice.ts - Form state management
  • curriculum.slice.ts - Course structure operations
  • sync.slice.ts - Backend synchronization
  • step.slice.ts - Step completion tracking
  • step-handler-registry.ts - Handler registry implementation

Hooks (instructor/src/hooks/)

  • useEditorNavigation.ts - Enhanced navigation with validation
  • useCourseEditorInitialization.ts - Editor initialization logic
  • useStepSubmission.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

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;
};
}
// 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

  1. Decoupling: Navigation doesn't know about step internals
  2. Testability: Each handler can be tested independently
  3. Flexibility: Easy to add new steps without modifying navigation
  4. Type Safety: Common interface ensures consistency
  5. 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

  1. Create step component (pages/editor/NewStep.tsx)
  2. Add to initial state (stores/course-editor/types.ts)
  3. Create form slice methods (if needed)
  4. Add validation schema (utils/schema/course.schema.ts)
  5. Register in navigation (EditorStep enum)
  6. Add to tabs array (CourseEditor.tsx)
  7. Implement step handler (using useStepSubmission)

Adding a New Entity Type

  1. Define interface (utils/types.ts)
  2. Add to store state (stores/course-editor/state.ts)
  3. Create CRUD methods (new or existing slice)
  4. Add sync methods (sync.slice.ts)
  5. Create service (services/new-entity.service.ts)
  6. 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