Component Composition
Best practices for building React components in the platform app.
Core Principles
- Composition over configuration - Build with small, focused components
- Server Components by default - Only use Client Components when necessary
- Props down, events up - Data flows down, actions bubble up
- No prop drilling - Use composition patterns instead
Component Types
| Type | Location | Purpose |
|---|---|---|
| Page | app/[feature]/page.tsx | Data fetching, layout assembly |
| Layout | app/[feature]/layout.tsx | Shared UI shell |
| Feature | app/[feature]/components/ | Feature-specific UI |
| Shared | components/shared/ | Reusable across features |
| UI | @repo/luz | Design 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 Case | Example |
|---|---|
| UI state shared across components | Selected items, expanded panels, active tabs |
| Temporary client state | Draft form data before submission |
| Optimistic updates | Show pending state while mutation runs |
| Modal/dialog state | Open/close state accessed from multiple places |
| Client-side filters | Filter state that doesn't need URL persistence |
Don't Use Zustand For
| Use Case | Use Instead |
|---|---|
| Server data | React Query |
| URL-persistent state | Query parameters (useSearchParams) |
| Form state | React Hook Form or useState |
| Single component state | useState |
| Auth/user data | Server 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 → ZustandZustand 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 draftsComponent File Structure
Single Component
app/users/components/
└── user-card.tsxComplex 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/luzfor UI primitives - File uses kebab-case naming