Building Truly Reusable React Components

How we built a generic EntityRelationshipViewer that replaced 12 type-specific components while maintaining full TypeScript safety and developer experience.

How we built a generic EntityRelationshipViewer that replaced 12 type-specific components while maintaining full TypeScript safety and developer experience.

Relationship viewers were the messiest corner of our dashboard. We had a component for clusters → workflows, another for users → clusters, another for workflows → users. Each of the twelve variants fetched different entities, but structurally they were clones: load some data, render a card, show a badge. The only thing that truly changed was the relationship being described.

Instead of polishing each component in isolation, we stepped back and asked: what is the invariant? This article rebuilds the feature from first principles so that one viewer can describe any relationship in the system.

When duplication finally hurts

The warning signs were familiar:

  • Fetching logic was copy-pasted, so caching bugs multiplied overnight.
  • Design tweaks required touching a dozen files to keep paddings and typography aligned.
  • New relationship types meant copying the longest existing component and hoping QA noticed the TODOs we forgot to delete.

Once we named the invariant—“render items that sit on the other side of a relationship”—we could collapse an entire directory of components into a single, predictable viewer.

Step 1: Model the relationship explicitly

Instead of encoding behavior in component names, we describe every relationship with a tiny data structure. That model tells the viewer which entity is the source, how to load the targets, and nothing else.

relationship.ts
1 type EntityType = 'cluster' | 'workflow' | 'user';
2
3 type RelationshipKind = `${EntityType}->${EntityType}`;
4
5 interface RelationshipDefinition<SourceId, Target> {
6 kind: RelationshipKind;
7 sourceId: SourceId;
8 fetch: (sourceId: SourceId) => Promise<Target[]>;
9 }

Every viewer receives one of these definitions. No conditional branches, no stringly-typed props—the relationship itself is now first-class data.

Step 2: Stream the data predictably

Because the relationship definition knows how to load its targets, a simple hook can handle all fetching, caching, and loading state. I use React Query, but any cache will do as long as you key on the relationship kind and source id.

useRelationship.tsx
1 export function useRelationship<SourceId, Target>(definition: RelationshipDefinition<SourceId, Target>) {
2 const queryKey = ['relationship', definition.kind, definition.sourceId];
3
4 return useQuery({
5 queryKey,
6 queryFn: () => definition.fetch(definition.sourceId),
7 });
8 }

The viewer no longer imports bespoke fetch functions. Data always flows through the same hook, which makes caching and error handling straightforward.

Step 3: Render with a registry instead of conditionals

Rendering is the only place that should “know” how workflows differ from clusters. A tiny registry keeps icons, labels, and metadata together so the viewer can stay generic.

entityConfig.ts
1 const entityConfig = {
2 workflow: {
3 icon: WorkflowIcon,
4 label: (workflow: Workflow) => workflow.name,
5 meta: (workflow: Workflow) => `${workflow.steps.length} steps`,
6 link: (workflow: Workflow) => `/workflows/${workflow.id}`,
7 },
8 cluster: {
9 icon: ClusterIcon,
10 label: (cluster: Cluster) => cluster.name,
11 meta: (cluster: Cluster) => `${cluster.nodeCount} nodes`,
12 link: (cluster: Cluster) => `/clusters/${cluster.id}`,
13 },
14 user: {
15 icon: UserIcon,
16 label: (user: User) => user.name,
17 meta: (user: User) => user.email,
18 link: (user: User) => `/users/${user.id}`,
19 },
20 } as const;
21
22 type EntityKind = keyof typeof entityConfig;
23
24 export function getEntityConfig(kind: EntityKind) {
25 return entityConfig[kind];
26 }

Adding a new entity type is now as easy as adding a new entry to this registry. No viewer code needs to change.

Step 4: Compose the viewer

When the relationship model, the data hook, and the registry are in place, the viewer itself is just glue code.

EntityRelationshipViewer.tsx
1 export function EntityRelationshipViewer<SourceId, Target>({ definition }: {
2 definition: RelationshipDefinition<SourceId, Target>;
3 }) {
4 const { data = [], isLoading, error } = useRelationship(definition);
5
6 if (isLoading) return <SkeletonRows title="Loading relationships" />;
7 if (error) return <ErrorCallout message={(error as Error).message} />;
8
9 const [, targetKind] = definition.kind.split('->');
10 const config = getEntityConfig(targetKind as EntityKind);
11
12 return (
13 <section aria-labelledby={`relationship-${definition.kind}`} className="relationship">
14 <h3 id={`relationship-${definition.kind}`}>{formatRelationshipTitle(definition)}</h3>
15 <ul className="relationship-grid">
16 {data.map((item: Target & { id: string }) => (
17 <li key={item.id}>
18 <Link href={config.link(item)} className="relationship-card">
19 <config.icon />
20 <div>
21 <p>{config.label(item)}</p>
22 <span>{config.meta(item)}</span>
23 </div>
24 </Link>
25 </li>
26 ))}
27 </ul>
28 </section>
29 );
30 }

One component now covers every relationship in the product. The UI stays consistent, and the only things that vary are data and configuration.

Step 5: Provide type-safe shortcuts

Teams should not memorize relationship names. Helper functions keep call sites readable and give TypeScript a chance to prevent typos.

relationships.ts
1 export const clusterWorkflows = (clusterId: Cluster['id']): RelationshipDefinition<Cluster['id'], Workflow> => ({
2 kind: 'cluster->workflow',
3 sourceId: clusterId,
4 fetch: fetchClusterWorkflows,
5 });
6
7 export const userClusters = (userId: User['id']): RelationshipDefinition<User['id'], Cluster> => ({
8 kind: 'user->cluster',
9 sourceId: userId,
10 fetch: fetchUserClusters,
11 });

Adding a new relationship now means adding a helper like these plus a registry entry. The viewer never changes.

Testing and ergonomics checklist

A reusable component is only useful if people trust it. A quick checklist kept ours healthy:

  • Contract tests: Render the viewer with a mocked definition and assert that icons, labels, and links all come from the registry—not from ad-hoc strings.
  • Storybook coverage: One story per relationship keeps design drift visible.
  • Cache analytics: Monitor cache hit rates by relationship key to spot new combinations that might need prefetching.
  • Documentation: A short README explains how to add helpers and configuration entries so engineers do not fall back to bespoke viewers.

Wrap-up

The trick to “generic” components is not clever TypeScript. It is putting the right concept at the center. Once we treated relationships as data instead of component names, everything else fell into place: a predictable viewer, consistent styling, and an API that feels obvious to the teams who rely on it.

If your repo is full of almost-identical viewers, resist the urge to add another prop. Find the invariant. Model it explicitly. Then let configuration and data flow do the rest.