- 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.
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();
});
});
Related Documents
- 01-architecture.md - System architecture
- 03-tdd-strategy.md - Testing strategy
- 04-example-ld-mapping.md - Example LD reuse patterns
- 05-d3-visualization.md - D3.js patterns