Fair Supply LogoFair Supply - Docs

Component Composition

Best practices for building React components in the platform app.

Core Principles

  1. Composition over configuration - Build with small, focused components
  2. Server Components by default - Only use Client Components when necessary
  3. Props down, events up - Data flows down, actions bubble up
  4. No prop drilling - Use composition patterns instead

Component Types

TypeLocationPurpose
Pageapp/[feature]/page.tsxData fetching, layout assembly
Layoutapp/[feature]/layout.tsxShared UI shell
Featureapp/[feature]/components/Feature-specific UI
Sharedcomponents/shared/Reusable across features
UI@repo/luzDesign system primitives

Composition Patterns

1. Compound Components

Group related components that work together:

// Usage
<Card>
  <Card.Header>
    <Card.Title>Settings</Card.Title>
  </Card.Header>
  <Card.Body>
    <p>Content here</p>
  </Card.Body>
  <Card.Footer>
    <Button>Save</Button>
  </Card.Footer>
</Card>

2. Slot Pattern (Children)

Pass components as children for flexible layouts:

// Definition
function PageLayout({ children }: { children: React.ReactNode }) {
  return (
    <LayoutContainer>
      <Stack gap="6">{children}</Stack>
    </LayoutContainer>
  );
}

// Usage
<PageLayout>
  <PageHeader title="Dashboard" />
  <DashboardContent />
</PageLayout>

3. Render Props (Named Slots)

Use named props for multiple insertion points:

// Definition
interface PageProps {
  header: React.ReactNode;
  sidebar?: React.ReactNode;
  children: React.ReactNode;
}

function Page({ header, sidebar, children }: PageProps) {
  return (
    <div className="page">
      <header>{header}</header>
      <main>
        {sidebar && <aside>{sidebar}</aside>}
        <section>{children}</section>
      </main>
    </div>
  );
}

// Usage
<Page
  header={<PageHeader title="Settings" />}
  sidebar={<SettingsNav />}
>
  <GeneralSettings />
</Page>

4. Component Injection

Pass components as props for customisation:

// Definition
interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
  emptyState?: React.ReactNode;
}

function List<T>({ items, renderItem, emptyState }: ListProps<T>) {
  if (items.length === 0) {
    return emptyState ?? <p>No items</p>;
  }
  return <ul>{items.map(renderItem)}</ul>;
}

// Usage
<List
  items={users}
  renderItem={(user) => <UserCard key={user.id} user={user} />}
  emptyState={<EmptyUsersMessage />}
/>

Server vs Client Components

Server Components (Default)

// app/users/page.tsx
import { UserRepository } from '@repo/core';

export default async function UsersPage() {
  const users = await UserRepository.findAll();

  return (
    <LayoutContainer>
      <UserList users={users} />
    </LayoutContainer>
  );
}

Client Components (When Needed)

Only add 'use client' when you need:

  • Event handlers (onClick, onChange)
  • State (useState, useReducer)
  • Effects (useEffect)
  • Browser APIs (localStorage, window)
// app/users/components/user-search.tsx
'use client';

import { useState } from 'react';

export function UserSearch({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState('');

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        onSearch(e.target.value);
      }}
    />
  );
}

Mixing Server and Client

Keep Client Components at the leaves:

// page.tsx (Server Component)
import { UserRepository } from '@repo/core';
import { UserTable } from './components/user-table';

export default async function UsersPage() {
  const users = await UserRepository.findAll();

  return (
    <LayoutContainer>
      <PageHeader title="Users" />
      {/* Client Component receives server data as props */}
      <UserTable users={users} />
    </LayoutContainer>
  );
}

// components/user-table.tsx (Client Component)
'use client';

export function UserTable({ users }: { users: User[] }) {
  const [selected, setSelected] = useState<string[]>([]);
  // Interactive table logic...
}

Avoiding Prop Drilling

Problem: Deep Prop Passing

// BAD - props passed through multiple layers
<Page user={user}>
  <Sidebar user={user}>
    <UserMenu user={user}>
      <Avatar user={user} />
    </UserMenu>
  </Sidebar>
</Page>

Solution 1: Composition

// GOOD - compose at the top level
<Page>
  <Sidebar>
    <UserMenu>
      <Avatar user={user} />
    </UserMenu>
  </Sidebar>
</Page>

Solution 2: Context (Sparingly)

// For truly global state only
const UserContext = createContext<User | null>(null);

function UserProvider({ user, children }: { user: User; children: React.ReactNode }) {
  return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}

function Avatar() {
  const user = useContext(UserContext);
  return <img src={user?.avatarUrl} />;
}

Solution 3: Zustand (Client State)

// stores/user-store.ts
import { create } from 'zustand';

interface UserStore {
  selectedUserId: string | null;
  setSelectedUser: (id: string | null) => void;
}

export const useUserStore = create<UserStore>((set) => ({
  selectedUserId: null,
  setSelectedUser: (id) => set({ selectedUserId: id }),
}));

// Usage in any component
function UserRow({ user }: { user: User }) {
  const setSelectedUser = useUserStore((s) => s.setSelectedUser);
  return <button onClick={() => setSelectedUser(user.id)}>{user.name}</button>;
}

When to Use Zustand

Zustand is for client-side UI state that doesn't belong in the URL or server.

Use Zustand For

Use CaseExample
UI state shared across componentsSelected items, expanded panels, active tabs
Temporary client stateDraft form data before submission
Optimistic updatesShow pending state while mutation runs
Modal/dialog stateOpen/close state accessed from multiple places
Client-side filtersFilter state that doesn't need URL persistence

Don't Use Zustand For

Use CaseUse Instead
Server dataReact Query
URL-persistent stateQuery parameters (useSearchParams)
Form stateReact Hook Form or useState
Single component stateuseState
Auth/user dataServer Components + props

Decision Flow

Is this state needed across multiple components?
├── No → useState
└── Yes → Should it persist in the URL?
    ├── Yes → Query parameters
    └── No → Is it server data?
        ├── Yes → React Query
        └── No → Zustand

Zustand Patterns

Keep stores small and focused:

// GOOD - focused store
export const useSelectionStore = create<SelectionStore>((set) => ({
  selectedIds: [],
  toggle: (id) => set((s) => ({
    selectedIds: s.selectedIds.includes(id)
      ? s.selectedIds.filter((i) => i !== id)
      : [...s.selectedIds, id],
  })),
  clear: () => set({ selectedIds: [] }),
}));

Use selectors to prevent re-renders:

// BAD - component re-renders on any store change
const { selectedIds, toggle } = useSelectionStore();

// GOOD - only re-renders when selectedIds changes
const selectedIds = useSelectionStore((s) => s.selectedIds);
const toggle = useSelectionStore((s) => s.toggle);

Reset state on navigation:

// In a layout or page component
'use client';

import { useEffect } from 'react';
import { useSelectionStore } from '@/stores/selection-store';

export function PageWrapper({ children }) {
  const clear = useSelectionStore((s) => s.clear);

  useEffect(() => {
    return () => clear(); // Clear on unmount
  }, [clear]);

  return <>{children}</>;
}

Store File Location

apps/web/src/
└── stores/
    ├── selection-store.ts    # Multi-select state
    ├── sidebar-store.ts      # Sidebar open/close
    └── draft-store.ts        # Unsaved form drafts

Component File Structure

Single Component

app/users/components/
└── user-card.tsx

Complex Component

app/users/components/
└── user-table/
    ├── index.tsx           # Main export
    ├── user-table.tsx      # Component implementation
    ├── user-table-row.tsx  # Sub-component
    ├── user-table-header.tsx
    └── use-user-table.ts   # Hook (if needed)

Props Best Practices

Use Specific Props

// BAD - too generic
interface CardProps {
  data: any;
  options: Record<string, unknown>;
}

// GOOD - explicit and typed
interface UserCardProps {
  user: User;
  showEmail?: boolean;
  onSelect?: (userId: string) => void;
}

Destructure with Defaults

function UserCard({
  user,
  showEmail = true,
  onSelect,
}: UserCardProps) {
  // ...
}

Spread Remaining Props

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
}

function Button({ variant = 'primary', children, ...props }: ButtonProps) {
  return (
    <button className={variant} {...props}>
      {children}
    </button>
  );
}

Activity-Tracked Navigation Buttons

When navigating a user to the Spotlight or Analyst page for an organisation, always use the dedicated shared button components. These trigger background report generation (via Inngest) on click, ensuring a fresh report is generated whenever a user navigates to these pages.

ComponentLocationNavigates toTriggers
SpotlightButtoncomponents/shared/spotlight-button.tsx/spotlight/:idreport.generate.spotlight
AnalystButtoncomponents/shared/analyst-button.tsx/analyst/:idreport.generate.analyst

Usage

Both accept a label prop (the caller provides the translated string) and optional variant, size, and colorPalette overrides.

import { AnalystButton } from '@/components/shared/analyst-button';
import { SpotlightButton } from '@/components/shared/spotlight-button';

// Defaults: variant="secondary", size="small" (no default colorPalette)
<AnalystButton organisationId={org.id} label={t('analyst')} />

// Defaults: variant="action", size="medium", colorPalette="black"
<SpotlightButton organisationId={org.id} label={t('spotlight')} />

// Override styling when needed
<AnalystButton organisationId={org.id} label={t('analyst')} variant="primary" colorPalette="black" />

When to use

  • Always use these when you want to navigate to Spotlight or Analyst for an organisation.
  • Do not use a plain <Link> or <Button as={Link}> for these routes — activity tracking will be missed.
  • Plain <Link> is fine for non-button contexts (e.g. an organisation name in a table cell that links to Spotlight) where triggering report generation is not appropriate.

Anti-Patterns

Don't: Conditional Component Types

// BAD
function Card({ asLink, href, children }) {
  if (asLink) {
    return <a href={href}>{children}</a>;
  }
  return <div>{children}</div>;
}

// GOOD - separate components
function Card({ children }) {
  return <div>{children}</div>;
}

function CardLink({ href, children }) {
  return <a href={href}>{children}</a>;
}

Don't: God Components

// BAD - does too much
function UserDashboard() {
  // 500 lines of mixed concerns
}

// GOOD - composed of focused components
function UserDashboard() {
  return (
    <DashboardLayout>
      <UserStats />
      <RecentActivity />
      <QuickActions />
    </DashboardLayout>
  );
}

Don't: Premature Abstraction

// BAD - abstracted too early
function GenericCard<T>({ data, renderHeader, renderBody, renderFooter }) {
  // Complex generic logic for a single use case
}

// GOOD - concrete first, abstract later
function UserCard({ user }) {
  return (
    <Card>
      <Card.Header>{user.name}</Card.Header>
      <Card.Body>{user.email}</Card.Body>
    </Card>
  );
}

Checklist

  • Component has a single responsibility
  • Server Component unless interactivity required
  • No prop drilling (use composition)
  • Props are typed and specific
  • Uses @repo/luz for UI primitives
  • File uses kebab-case naming