# 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. ```typescript // Base component interface CardProps { children: React.ReactNode; className?: string; } export const Card: React.FC = ({ children, className = '' }) => { return (
{children}
); }; // Composed components export const CardHeader: React.FC<{ children: React.ReactNode }> = ({ children }) => { return
{children}
; }; export const CardBody: React.FC<{ children: React.ReactNode }> = ({ children }) => { return
{children}
; }; // Usage

Rijksmuseum

National museum of the Netherlands

``` ### 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). ```typescript // Presenter component (dumb) interface CustodianListViewProps { custodians: CustodianObservation[]; isLoading: boolean; error?: Error; onSelect: (custodian: CustodianObservation) => void; } export const CustodianListView: React.FC = ({ custodians, isLoading, error, onSelect, }) => { if (isLoading) return ; if (error) return ; return (
{custodians.map((custodian) => ( onSelect(custodian)} /> ))}
); }; // 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 ( ); }; ``` ### 3. Custom Hook Pattern **Problem**: Reusing stateful logic across components. **Solution**: Extract logic into custom hooks. ```typescript // 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(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. ```typescript interface DataFetcherProps { url: string; children: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode; } function DataFetcher({ url, children }: DataFetcherProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { fetch(url) .then(res => res.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)); }, [url]); return <>{children(data, loading, error)}; } // Usage url="/api/custodians"> {(custodians, loading, error) => { if (loading) return ; if (error) return ; return ; }} ``` ### 5. Higher-Order Component (HOC) Pattern **Problem**: Adding common functionality to multiple components. **Solution**: Wrap components with HOCs to inject functionality. ```typescript // HOC for authentication function withAuth

( Component: React.ComponentType

): React.FC

{ return (props: P) => { const { user, isLoading } = useAuth(); const navigate = useNavigate(); useEffect(() => { if (!isLoading && !user) { navigate('/login'); } }, [user, isLoading, navigate]); if (isLoading) return ; if (!user) return null; return ; }; } // Usage const ProtectedDashboard = withAuth(Dashboard); // HOC for error boundary function withErrorBoundary

( Component: React.ComponentType

, fallback?: React.ComponentType<{ error: Error }> ): React.FC

{ return class WithErrorBoundary extends React.Component { 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 ; } return ; } }; } ``` ### 6. Factory Pattern for Visualizations **Problem**: Creating different types of visualizations based on data type. **Solution**: Use factory pattern to instantiate appropriate visualization. ```typescript // 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 | 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. ```typescript type EventCallback = (data: any) => void; class EventEmitter { private events: Map> = 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 ; } // Emit event from another component function CustodianList() { const handleClick = (custodian: CustodianObservation) => { eventBus.emit('custodian:selected', custodian); }; return (

{custodians.map(c => ( ))}
); } ``` ### 8. Builder Pattern for Complex Queries **Problem**: Constructing complex SPARQL queries with many optional parts. **Solution**: Use builder pattern for fluent query construction. ```typescript class SPARQLQueryBuilder { private prefixes: Map = 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. ```typescript 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 = 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. ```typescript interface RDFAdapter { parse(content: string): Promise; serialize(triples: Triple[]): Promise; validate(content: string): boolean; } class TurtleAdapter implements RDFAdapter { async parse(content: string): Promise { 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 { 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 { const doc = JSON.parse(content); const nquads = await jsonld.toRDF(doc, { format: 'application/n-quads' }); return parseNQuads(nquads); } async serialize(triples: Triple[]): Promise { 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 = 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. ```typescript interface TabsContextValue { activeTab: string; setActiveTab: (tab: string) => void; } const TabsContext = createContext(null); function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) { const [activeTab, setActiveTab] = useState(defaultTab); return (
{children}
); } function TabList({ children }: { children: React.ReactNode }) { return
{children}
; } 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 ( ); } 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
{children}
; } // Export compound component export const TabsComponent = Object.assign(Tabs, { List: TabList, Tab: Tab, Panel: TabPanel, }); // Usage Overview Visualizations Query ``` ### 12. Controlled vs Uncontrolled Components **Problem**: Balancing component flexibility and control. **Solution**: Support both controlled and uncontrolled modes. ```typescript 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 (
handleChange(e.target.value)} placeholder={placeholder} />
); } // Uncontrolled usage performSearch(query)} /> // Controlled usage const [searchQuery, setSearchQuery] = useState(''); performSearch(query)} /> ``` ## State Management Patterns ### 13. Zustand Store Slices **Problem**: Organizing large state stores. **Solution**: Split store into logical slices. ```typescript // stores/slices/rdfSlice.ts export interface RDFSlice { triples: Triple[]; format: RDFFormat; isLoading: boolean; error: Error | null; loadRDF: (content: string, format: RDFFormat) => Promise; clearRDF: () => void; } export const createRDFSlice: StateCreator = (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; } export const createQuerySlice: StateCreator = (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()( 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. ```typescript 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 ; } // Memoized component (React.memo) export const CustodianCard = React.memo( ({ custodian, onClick }) => { return (
onClick(custodian)}>

{custodian.name}

{custodian.institution_type}

); }, (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. ```typescript // Custom hook for debouncing function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(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 ( setSearchTerm(e.target.value)} placeholder="Search custodians..." /> ); } // Custom hook for throttling function useThrottle any>( callback: T, delay: number ): T { const lastRun = useRef(Date.now()); return useCallback((...args: Parameters) => { 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
...
; } ``` ## Error Handling Patterns ### 16. Error Boundary Pattern **Problem**: Gracefully handling component errors. **Solution**: Implement error boundaries. ```typescript interface ErrorBoundaryProps { children: React.ReactNode; fallback?: React.ComponentType<{ error: Error; reset: () => void }>; } interface ErrorBoundaryState { hasError: boolean; error?: Error; } export class ErrorBoundary extends React.Component { 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 ; } return this.props.children; } } // Default fallback component function DefaultErrorFallback({ error, reset }: { error: Error; reset: () => void }) { return (

Something went wrong

{error.message}
); } // Usage ``` ## Testing Patterns ### 17. Test Data Builders **Problem**: Creating test data is repetitive and brittle. **Solution**: Use builder pattern for test data. ```typescript class CustodianBuilder { private custodian: Partial = { 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(); expect(screen.getByText('Rijksmuseum')).toBeInTheDocument(); expect(screen.getByText('MUSEUM')).toBeInTheDocument(); }); }); ``` ## Related Documents - [01-architecture.md](01-architecture.md) - System architecture - [03-tdd-strategy.md](03-tdd-strategy.md) - Testing strategy - [04-example-ld-mapping.md](04-example-ld-mapping.md) - Example LD reuse patterns - [05-d3-visualization.md](05-d3-visualization.md) - D3.js patterns