glam/docs/plan/frontend/02-design-patterns.md
kempersc fa5680f0dd Add initial versions of custodian hub UML diagrams in Mermaid and PlantUML formats
- Introduced custodian_hub_v3.mmd, custodian_hub_v4_final.mmd, and custodian_hub_v5_FINAL.mmd for Mermaid representation.
- Created custodian_hub_FINAL.puml and custodian_hub_v3.puml for PlantUML representation.
- Defined entities such as CustodianReconstruction, Identifier, TimeSpan, Agent, CustodianName, CustodianObservation, ReconstructionActivity, Appellation, ConfidenceMeasure, Custodian, LanguageCode, and SourceDocument.
- Established relationships and associations between entities, including temporal extents, observations, and reconstruction activities.
- Incorporated enumerations for various types, statuses, and classifications relevant to custodians and their activities.
2025-11-22 14:33:51 +01:00

30 KiB

Frontend Design Patterns

Overview

This document outlines the design patterns to be used in the Heritage Custodian Frontend application. These patterns ensure consistency, maintainability, and scalability across the codebase.

Core Design Patterns

1. Component Composition Pattern

Problem: Need to build complex UI from simple, reusable components.

Solution: Use composition over inheritance with React components.

// Base component
interface CardProps {
  children: React.ReactNode;
  className?: string;
}

export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
  return (
    <div className={`rounded-lg shadow-md p-4 ${className}`}>
      {children}
    </div>
  );
};

// Composed components
export const CardHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <div className="border-b pb-2 mb-3">{children}</div>;
};

export const CardBody: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  return <div className="text-gray-700">{children}</div>;
};

// Usage
<Card>
  <CardHeader>
    <h3>Rijksmuseum</h3>
  </CardHeader>
  <CardBody>
    <p>National museum of the Netherlands</p>
  </CardBody>
</Card>

2. Container/Presenter Pattern

Problem: Mixing business logic with presentation logic makes components hard to test and reuse.

Solution: Separate containers (smart components) from presenters (dumb components).

// Presenter component (dumb)
interface CustodianListViewProps {
  custodians: CustodianObservation[];
  isLoading: boolean;
  error?: Error;
  onSelect: (custodian: CustodianObservation) => void;
}

export const CustodianListView: React.FC<CustodianListViewProps> = ({
  custodians,
  isLoading,
  error,
  onSelect,
}) => {
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {custodians.map((custodian) => (
        <CustodianCard
          key={custodian.id}
          custodian={custodian}
          onClick={() => onSelect(custodian)}
        />
      ))}
    </div>
  );
};

// Container component (smart)
export const CustodianListContainer: React.FC = () => {
  const { data, isLoading, error } = useQuery('custodians', fetchCustodians);
  const navigate = useNavigate();
  
  const handleSelect = (custodian: CustodianObservation) => {
    navigate(`/custodian/${custodian.id}`);
  };
  
  return (
    <CustodianListView
      custodians={data || []}
      isLoading={isLoading}
      error={error}
      onSelect={handleSelect}
    />
  );
};

3. Custom Hook Pattern

Problem: Reusing stateful logic across components.

Solution: Extract logic into custom hooks.

// Custom hook for RDF operations
export function useRDF(initialFormat: RDFFormat = 'turtle') {
  const [store, setStore] = useState(() => new N3.Store());
  const [format, setFormat] = useState(initialFormat);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  const loadRDF = useCallback(async (content: string) => {
    setIsLoading(true);
    setError(null);
    
    try {
      const parser = new N3.Parser({ format });
      const newStore = new N3.Store();
      
      parser.parse(content, (error, quad) => {
        if (error) throw error;
        if (quad) newStore.addQuad(quad);
      });
      
      setStore(newStore);
    } catch (err) {
      setError(err as Error);
    } finally {
      setIsLoading(false);
    }
  }, [format]);
  
  const query = useCallback((sparql: string) => {
    // SPARQL query execution
    const engine = new SPARQLEngine(store);
    return engine.execute(sparql);
  }, [store]);
  
  return {
    store,
    format,
    setFormat,
    loadRDF,
    query,
    isLoading,
    error,
  };
}

// Usage in component
function RDFExplorer() {
  const { store, loadRDF, query, isLoading } = useRDF();
  
  // Component logic using the hook
}

4. Render Props Pattern

Problem: Sharing component logic while maintaining flexibility in rendering.

Solution: Use render props for flexible component composition.

interface DataFetcherProps<T> {
  url: string;
  children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
}

function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
  
  return <>{children(data, loading, error)}</>;
}

// Usage
<DataFetcher<CustodianObservation[]> url="/api/custodians">
  {(custodians, loading, error) => {
    if (loading) return <Spinner />;
    if (error) return <Error message={error.message} />;
    return <CustodianList custodians={custodians!} />;
  }}
</DataFetcher>

5. Higher-Order Component (HOC) Pattern

Problem: Adding common functionality to multiple components.

Solution: Wrap components with HOCs to inject functionality.

// HOC for authentication
function withAuth<P extends object>(
  Component: React.ComponentType<P>
): React.FC<P> {
  return (props: P) => {
    const { user, isLoading } = useAuth();
    const navigate = useNavigate();
    
    useEffect(() => {
      if (!isLoading && !user) {
        navigate('/login');
      }
    }, [user, isLoading, navigate]);
    
    if (isLoading) return <LoadingSpinner />;
    if (!user) return null;
    
    return <Component {...props} />;
  };
}

// Usage
const ProtectedDashboard = withAuth(Dashboard);

// HOC for error boundary
function withErrorBoundary<P extends object>(
  Component: React.ComponentType<P>,
  fallback?: React.ComponentType<{ error: Error }>
): React.FC<P> {
  return class WithErrorBoundary extends React.Component<P, { hasError: boolean; error?: Error }> {
    constructor(props: P) {
      super(props);
      this.state = { hasError: false };
    }
    
    static getDerivedStateFromError(error: Error) {
      return { hasError: true, error };
    }
    
    render() {
      if (this.state.hasError) {
        const Fallback = fallback || DefaultErrorFallback;
        return <Fallback error={this.state.error!} />;
      }
      
      return <Component {...this.props} />;
    }
  };
}

6. Factory Pattern for Visualizations

Problem: Creating different types of visualizations based on data type.

Solution: Use factory pattern to instantiate appropriate visualization.

// Visualization factory
type VisualizationType = 'network' | 'timeline' | 'map' | 'tree' | 'chart';

interface Visualization {
  render(container: HTMLElement, data: any): void;
  update(data: any): void;
  destroy(): void;
}

class NetworkVisualization implements Visualization {
  private simulation: d3.Simulation<any, any> | null = null;
  
  render(container: HTMLElement, data: { nodes: Node[], edges: Edge[] }): void {
    const svg = d3.select(container).append('svg');
    this.simulation = d3.forceSimulation(data.nodes)
      .force('link', d3.forceLink(data.edges))
      .force('charge', d3.forceManyBody())
      .force('center', d3.forceCenter());
    
    // Rendering logic
  }
  
  update(data: any): void {
    if (this.simulation) {
      this.simulation.nodes(data.nodes);
      this.simulation.alpha(1).restart();
    }
  }
  
  destroy(): void {
    if (this.simulation) {
      this.simulation.stop();
    }
  }
}

class TimelineVisualization implements Visualization {
  render(container: HTMLElement, data: Event[]): void {
    // Timeline rendering logic
  }
  
  update(data: Event[]): void {
    // Update timeline
  }
  
  destroy(): void {
    // Cleanup
  }
}

class VisualizationFactory {
  static create(type: VisualizationType): Visualization {
    switch (type) {
      case 'network':
        return new NetworkVisualization();
      case 'timeline':
        return new TimelineVisualization();
      case 'map':
        return new MapVisualization();
      case 'tree':
        return new TreeVisualization();
      case 'chart':
        return new ChartVisualization();
      default:
        throw new Error(`Unknown visualization type: ${type}`);
    }
  }
}

// Usage
const viz = VisualizationFactory.create('network');
viz.render(containerRef.current, graphData);

7. Observer Pattern (Pub/Sub)

Problem: Decoupling components that need to react to events.

Solution: Implement publish-subscribe pattern.

type EventCallback = (data: any) => void;

class EventEmitter {
  private events: Map<string, Set<EventCallback>> = new Map();
  
  on(event: string, callback: EventCallback): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, new Set());
    }
    this.events.get(event)!.add(callback);
    
    // Return unsubscribe function
    return () => this.off(event, callback);
  }
  
  off(event: string, callback: EventCallback): void {
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.delete(callback);
    }
  }
  
  emit(event: string, data?: any): void {
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.forEach(callback => callback(data));
    }
  }
  
  clear(): void {
    this.events.clear();
  }
}

// Singleton instance
export const eventBus = new EventEmitter();

// Usage in component
function CustodianMap() {
  useEffect(() => {
    const unsubscribe = eventBus.on('custodian:selected', (custodian) => {
      // Highlight custodian on map
      highlightMarker(custodian.id);
    });
    
    return unsubscribe; // Cleanup on unmount
  }, []);
  
  return <MapContainer />;
}

// Emit event from another component
function CustodianList() {
  const handleClick = (custodian: CustodianObservation) => {
    eventBus.emit('custodian:selected', custodian);
  };
  
  return (
    <div>
      {custodians.map(c => (
        <button onClick={() => handleClick(c)}>{c.name}</button>
      ))}
    </div>
  );
}

8. Builder Pattern for Complex Queries

Problem: Constructing complex SPARQL queries with many optional parts.

Solution: Use builder pattern for fluent query construction.

class SPARQLQueryBuilder {
  private prefixes: Map<string, string> = new Map();
  private selectVars: string[] = [];
  private wherePatterns: string[] = [];
  private filterExpressions: string[] = [];
  private orderByClauses: string[] = [];
  private limitValue?: number;
  private offsetValue?: number;
  
  prefix(prefix: string, uri: string): this {
    this.prefixes.set(prefix, uri);
    return this;
  }
  
  select(...vars: string[]): this {
    this.selectVars.push(...vars);
    return this;
  }
  
  where(pattern: string): this {
    this.wherePatterns.push(pattern);
    return this;
  }
  
  filter(expression: string): this {
    this.filterExpressions.push(expression);
    return this;
  }
  
  orderBy(clause: string): this {
    this.orderByClauses.push(clause);
    return this;
  }
  
  limit(value: number): this {
    this.limitValue = value;
    return this;
  }
  
  offset(value: number): this {
    this.offsetValue = value;
    return this;
  }
  
  build(): string {
    let query = '';
    
    // Add prefixes
    for (const [prefix, uri] of this.prefixes) {
      query += `PREFIX ${prefix}: <${uri}>\n`;
    }
    
    // Add SELECT
    query += `\nSELECT ${this.selectVars.join(' ')}\n`;
    
    // Add WHERE
    query += 'WHERE {\n';
    query += this.wherePatterns.map(p => `  ${p}`).join('\n');
    
    if (this.filterExpressions.length > 0) {
      query += '\n' + this.filterExpressions.map(f => `  FILTER(${f})`).join('\n');
    }
    
    query += '\n}\n';
    
    // Add ORDER BY
    if (this.orderByClauses.length > 0) {
      query += `ORDER BY ${this.orderByClauses.join(' ')}\n`;
    }
    
    // Add LIMIT and OFFSET
    if (this.limitValue !== undefined) {
      query += `LIMIT ${this.limitValue}\n`;
    }
    if (this.offsetValue !== undefined) {
      query += `OFFSET ${this.offsetValue}\n`;
    }
    
    return query;
  }
}

// Usage
const query = new SPARQLQueryBuilder()
  .prefix('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#')
  .prefix('hc', 'https://w3id.org/heritage/custodian/')
  .select('?custodian', '?name', '?type')
  .where('?custodian a hc:CustodianObservation .')
  .where('?custodian hc:name ?name .')
  .where('?custodian hc:institution_type ?type .')
  .filter('LANG(?name) = "en"')
  .orderBy('?name')
  .limit(50)
  .build();

9. Strategy Pattern for Export Formats

Problem: Supporting multiple export formats without complex conditionals.

Solution: Define strategy interface with concrete implementations for each format.

interface ExportStrategy {
  export(data: CustodianObservation[]): string | Blob;
  getFileExtension(): string;
  getMimeType(): string;
}

class JSONExportStrategy implements ExportStrategy {
  export(data: CustodianObservation[]): string {
    return JSON.stringify(data, null, 2);
  }
  
  getFileExtension(): string {
    return 'json';
  }
  
  getMimeType(): string {
    return 'application/json';
  }
}

class CSVExportStrategy implements ExportStrategy {
  export(data: CustodianObservation[]): string {
    const header = ['ID', 'Name', 'Type', 'City', 'Country'];
    const rows = data.map(c => [
      c.id,
      c.name,
      c.institution_type,
      c.locations?.[0]?.city || '',
      c.locations?.[0]?.country || '',
    ]);
    
    return [header, ...rows]
      .map(row => row.map(cell => `"${cell}"`).join(','))
      .join('\n');
  }
  
  getFileExtension(): string {
    return 'csv';
  }
  
  getMimeType(): string {
    return 'text/csv';
  }
}

class RDFTurtleExportStrategy implements ExportStrategy {
  export(data: CustodianObservation[]): string {
    const transformer = new CustodianTransformer();
    const triples = data.flatMap(c => transformer.toRDF(c));
    return serializeTriples(triples, 'turtle');
  }
  
  getFileExtension(): string {
    return 'ttl';
  }
  
  getMimeType(): string {
    return 'text/turtle';
  }
}

class ExportService {
  private strategies: Map<string, ExportStrategy> = new Map([
    ['json', new JSONExportStrategy()],
    ['csv', new CSVExportStrategy()],
    ['turtle', new RDFTurtleExportStrategy()],
  ]);
  
  export(data: CustodianObservation[], format: string): void {
    const strategy = this.strategies.get(format);
    if (!strategy) {
      throw new Error(`Unsupported export format: ${format}`);
    }
    
    const content = strategy.export(data);
    const blob = new Blob([content], { type: strategy.getMimeType() });
    const url = URL.createObjectURL(blob);
    
    const a = document.createElement('a');
    a.href = url;
    a.download = `custodians.${strategy.getFileExtension()}`;
    a.click();
    
    URL.revokeObjectURL(url);
  }
}

// Usage
const exportService = new ExportService();
exportService.export(selectedCustodians, 'csv');

10. Adapter Pattern for RDF Formats

Problem: Working with different RDF serialization formats uniformly.

Solution: Create adapters that provide consistent interface.

interface RDFAdapter {
  parse(content: string): Promise<Triple[]>;
  serialize(triples: Triple[]): Promise<string>;
  validate(content: string): boolean;
}

class TurtleAdapter implements RDFAdapter {
  async parse(content: string): Promise<Triple[]> {
    const parser = new N3.Parser({ format: 'turtle' });
    return new Promise((resolve, reject) => {
      const triples: Triple[] = [];
      parser.parse(content, (error, quad) => {
        if (error) reject(error);
        if (quad) triples.push(quadToTriple(quad));
        else resolve(triples);
      });
    });
  }
  
  async serialize(triples: Triple[]): Promise<string> {
    const writer = new N3.Writer({ format: 'turtle' });
    triples.forEach(t => writer.addQuad(tripleToQuad(t)));
    return new Promise((resolve, reject) => {
      writer.end((error, result) => {
        if (error) reject(error);
        else resolve(result);
      });
    });
  }
  
  validate(content: string): boolean {
    try {
      const parser = new N3.Parser({ format: 'turtle' });
      parser.parse(content);
      return true;
    } catch {
      return false;
    }
  }
}

class JSONLDAdapter implements RDFAdapter {
  async parse(content: string): Promise<Triple[]> {
    const doc = JSON.parse(content);
    const nquads = await jsonld.toRDF(doc, { format: 'application/n-quads' });
    return parseNQuads(nquads);
  }
  
  async serialize(triples: Triple[]): Promise<string> {
    const nquads = serializeToNQuads(triples);
    const doc = await jsonld.fromRDF(nquads, { format: 'application/n-quads' });
    return JSON.stringify(doc, null, 2);
  }
  
  validate(content: string): boolean {
    try {
      const doc = JSON.parse(content);
      return doc['@context'] !== undefined;
    } catch {
      return false;
    }
  }
}

class RDFAdapterFactory {
  private adapters: Map<RDFFormat, RDFAdapter> = new Map([
    ['turtle', new TurtleAdapter()],
    ['jsonld', new JSONLDAdapter()],
    ['ntriples', new NTriplesAdapter()],
    ['rdfxml', new RDFXMLAdapter()],
  ]);
  
  getAdapter(format: RDFFormat): RDFAdapter {
    const adapter = this.adapters.get(format);
    if (!adapter) {
      throw new Error(`No adapter found for format: ${format}`);
    }
    return adapter;
  }
}

// Usage
const factory = new RDFAdapterFactory();
const adapter = factory.getAdapter('turtle');
const triples = await adapter.parse(rdfContent);

UI Component Patterns

11. Compound Components Pattern

Problem: Creating components with implicit parent-child relationships.

Solution: Use React Context for compound components.

interface TabsContextValue {
  activeTab: string;
  setActiveTab: (tab: string) => void;
}

const TabsContext = createContext<TabsContextValue | null>(null);

function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabList({ children }: { children: React.ReactNode }) {
  return <div className="tab-list flex border-b">{children}</div>;
}

function Tab({ value, children }: { value: string; children: React.ReactNode }) {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tab must be used within Tabs');
  
  const isActive = context.activeTab === value;
  
  return (
    <button
      className={`px-4 py-2 ${isActive ? 'border-b-2 border-blue-500' : ''}`}
      onClick={() => context.setActiveTab(value)}
    >
      {children}
    </button>
  );
}

function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
  const context = useContext(TabsContext);
  if (!context) throw new Error('TabPanel must be used within Tabs');
  
  if (context.activeTab !== value) return null;
  
  return <div className="tab-panel p-4">{children}</div>;
}

// Export compound component
export const TabsComponent = Object.assign(Tabs, {
  List: TabList,
  Tab: Tab,
  Panel: TabPanel,
});

// Usage
<TabsComponent defaultTab="overview">
  <TabsComponent.List>
    <TabsComponent.Tab value="overview">Overview</TabsComponent.Tab>
    <TabsComponent.Tab value="visualizations">Visualizations</TabsComponent.Tab>
    <TabsComponent.Tab value="query">Query</TabsComponent.Tab>
  </TabsComponent.List>
  
  <TabsComponent.Panel value="overview">
    <OverviewContent />
  </TabsComponent.Panel>
  <TabsComponent.Panel value="visualizations">
    <VisualizationsContent />
  </TabsComponent.Panel>
  <TabsComponent.Panel value="query">
    <QueryContent />
  </TabsComponent.Panel>
</TabsComponent>

12. Controlled vs Uncontrolled Components

Problem: Balancing component flexibility and control.

Solution: Support both controlled and uncontrolled modes.

interface SearchBoxProps {
  // Controlled mode
  value?: string;
  onChange?: (value: string) => void;
  
  // Uncontrolled mode
  defaultValue?: string;
  
  // Common props
  placeholder?: string;
  onSearch?: (value: string) => void;
}

function SearchBox({
  value: controlledValue,
  onChange,
  defaultValue = '',
  placeholder = 'Search...',
  onSearch,
}: SearchBoxProps) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  
  // Determine if controlled or uncontrolled
  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : internalValue;
  
  const handleChange = (newValue: string) => {
    if (!isControlled) {
      setInternalValue(newValue);
    }
    onChange?.(newValue);
  };
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSearch?.(value);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={value}
        onChange={(e) => handleChange(e.target.value)}
        placeholder={placeholder}
      />
      <button type="submit">Search</button>
    </form>
  );
}

// Uncontrolled usage
<SearchBox
  defaultValue="Rijksmuseum"
  onSearch={(query) => performSearch(query)}
/>

// Controlled usage
const [searchQuery, setSearchQuery] = useState('');
<SearchBox
  value={searchQuery}
  onChange={setSearchQuery}
  onSearch={(query) => performSearch(query)}
/>

State Management Patterns

13. Zustand Store Slices

Problem: Organizing large state stores.

Solution: Split store into logical slices.

// stores/slices/rdfSlice.ts
export interface RDFSlice {
  triples: Triple[];
  format: RDFFormat;
  isLoading: boolean;
  error: Error | null;
  loadRDF: (content: string, format: RDFFormat) => Promise<void>;
  clearRDF: () => void;
}

export const createRDFSlice: StateCreator<RDFSlice> = (set, get) => ({
  triples: [],
  format: 'turtle',
  isLoading: false,
  error: null,
  
  loadRDF: async (content, format) => {
    set({ isLoading: true, error: null });
    try {
      const adapter = new RDFAdapterFactory().getAdapter(format);
      const triples = await adapter.parse(content);
      set({ triples, format, isLoading: false });
    } catch (error) {
      set({ error: error as Error, isLoading: false });
    }
  },
  
  clearRDF: () => set({ triples: [], error: null }),
});

// stores/slices/querySlice.ts
export interface QuerySlice {
  currentQuery: string;
  results: QueryResult[];
  isExecuting: boolean;
  setQuery: (query: string) => void;
  executeQuery: () => Promise<void>;
}

export const createQuerySlice: StateCreator<QuerySlice> = (set, get) => ({
  currentQuery: '',
  results: [],
  isExecuting: false,
  
  setQuery: (query) => set({ currentQuery: query }),
  
  executeQuery: async () => {
    set({ isExecuting: true });
    try {
      const { triples } = get() as any; // Access RDF slice
      const engine = new SPARQLEngine(triples);
      const results = await engine.execute(get().currentQuery);
      set({ results, isExecuting: false });
    } catch (error) {
      console.error('Query execution failed:', error);
      set({ isExecuting: false });
    }
  },
});

// stores/index.ts
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

type Store = RDFSlice & QuerySlice & UISlice;

export const useStore = create<Store>()(
  devtools(
    (...a) => ({
      ...createRDFSlice(...a),
      ...createQuerySlice(...a),
      ...createUISlice(...a),
    })
  )
);

Performance Patterns

14. Memoization Pattern

Problem: Expensive computations running on every render.

Solution: Use useMemo and useCallback appropriately.

function CustodianNetwork({ custodians }: { custodians: CustodianObservation[] }) {
  // Memoize expensive graph computation
  const graphData = useMemo(() => {
    const nodes = custodians.map(c => ({ id: c.id, label: c.name }));
    const edges = computeRelationships(custodians);
    return { nodes, edges };
  }, [custodians]); // Only recompute if custodians change
  
  // Memoize callback to prevent child re-renders
  const handleNodeClick = useCallback((nodeId: string) => {
    const custodian = custodians.find(c => c.id === nodeId);
    if (custodian) {
      eventBus.emit('custodian:selected', custodian);
    }
  }, [custodians]);
  
  return <NetworkGraph data={graphData} onNodeClick={handleNodeClick} />;
}

// Memoized component (React.memo)
export const CustodianCard = React.memo<CustodianCardProps>(
  ({ custodian, onClick }) => {
    return (
      <div onClick={() => onClick(custodian)}>
        <h3>{custodian.name}</h3>
        <p>{custodian.institution_type}</p>
      </div>
    );
  },
  (prevProps, nextProps) => {
    // Custom comparison function
    return prevProps.custodian.id === nextProps.custodian.id;
  }
);

15. Debouncing and Throttling

Problem: Handling high-frequency events efficiently.

Solution: Debounce user input, throttle scroll/resize events.

// Custom hook for debouncing
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

// Usage in search
function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearchTerm = useDebounce(searchTerm, 300);
  
  const { data } = useQuery(
    ['search', debouncedSearchTerm],
    () => searchCustodians(debouncedSearchTerm),
    { enabled: debouncedSearchTerm.length > 0 }
  );
  
  return (
    <input
      value={searchTerm}
      onChange={(e) => setSearchTerm(e.target.value)}
      placeholder="Search custodians..."
    />
  );
}

// Custom hook for throttling
function useThrottle<T extends (...args: any[]) => any>(
  callback: T,
  delay: number
): T {
  const lastRun = useRef(Date.now());
  
  return useCallback((...args: Parameters<T>) => {
    const now = Date.now();
    if (now - lastRun.current >= delay) {
      callback(...args);
      lastRun.current = now;
    }
  }, [callback, delay]) as T;
}

// Usage for scroll events
function InfiniteScrollList() {
  const loadMore = useThrottle(() => {
    // Load more items
  }, 200);
  
  useEffect(() => {
    window.addEventListener('scroll', loadMore);
    return () => window.removeEventListener('scroll', loadMore);
  }, [loadMore]);
  
  return <div>...</div>;
}

Error Handling Patterns

16. Error Boundary Pattern

Problem: Gracefully handling component errors.

Solution: Implement error boundaries.

interface ErrorBoundaryProps {
  children: React.ReactNode;
  fallback?: React.ComponentType<{ error: Error; reset: () => void }>;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }
  
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true, error };
  }
  
  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Error boundary caught error:', error, errorInfo);
    // Send to error tracking service
    reportError(error, errorInfo);
  }
  
  reset = () => {
    this.setState({ hasError: false, error: undefined });
  };
  
  render() {
    if (this.state.hasError && this.state.error) {
      const Fallback = this.props.fallback || DefaultErrorFallback;
      return <Fallback error={this.state.error} reset={this.reset} />;
    }
    
    return this.props.children;
  }
}

// Default fallback component
function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div className="error-boundary">
      <h2>Something went wrong</h2>
      <pre>{error.message}</pre>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

// Usage
<ErrorBoundary fallback={CustomErrorFallback}>
  <CustodianVisualization />
</ErrorBoundary>

Testing Patterns

17. Test Data Builders

Problem: Creating test data is repetitive and brittle.

Solution: Use builder pattern for test data.

class CustodianBuilder {
  private custodian: Partial<CustodianObservation> = {
    id: 'test-id',
    name: 'Test Museum',
    institution_type: 'MUSEUM',
  };
  
  withId(id: string): this {
    this.custodian.id = id;
    return this;
  }
  
  withName(name: string): this {
    this.custodian.name = name;
    return this;
  }
  
  withType(type: InstitutionType): this {
    this.custodian.institution_type = type;
    return this;
  }
  
  withLocation(city: string, country: string): this {
    this.custodian.locations = [{
      city,
      country,
      latitude: 0,
      longitude: 0,
    }];
    return this;
  }
  
  build(): CustodianObservation {
    return this.custodian as CustodianObservation;
  }
}

// Usage in tests
describe('CustodianCard', () => {
  it('displays museum information', () => {
    const museum = new CustodianBuilder()
      .withName('Rijksmuseum')
      .withType('MUSEUM')
      .withLocation('Amsterdam', 'NL')
      .build();
    
    render(<CustodianCard custodian={museum} />);
    
    expect(screen.getByText('Rijksmuseum')).toBeInTheDocument();
    expect(screen.getByText('MUSEUM')).toBeInTheDocument();
  });
});