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>
  );
}

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